Add example configuration file, moved everything from secrets.py to here.

Feat: Also refactored some of the logic in discord_webhook.py and networking.py to be more friendly towards the pico with ram usage.

Fixes #26
This commit is contained in:
Aaron 2025-11-14 16:50:53 -05:00
parent a20bbd7cdf
commit d95f212d2e
8 changed files with 266 additions and 134 deletions

View File

@ -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
except:
pass
# free large objects and modules, then force GC
try:
del resp
except:
pass
try:
gc.collect()
except:
pass

View File

@ -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):

View File

@ -1,19 +1,32 @@
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
@ -41,13 +54,13 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
# 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:
@ -62,7 +75,17 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
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
@ -85,17 +108,24 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
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!')

View File

@ -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')
@ -126,16 +155,17 @@ class ScheduleMonitor:
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
@ -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))
else:
# ensure in-memory webhook config updated
try:
import scripts.discord_webhook as discord_webhook
discord_webhook.set_config(self.config)
except Exception:
pass
# Notify user
try:
from scripts.discord_webhook import send_discord_message
send_discord_message("⏰ Temporary hold expired - Schedule resumed automatically")
except:
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

View File

@ -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

45
config.json.Example Normal file
View File

@ -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
}

10
main.py
View File

@ -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,

View File

@ -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
}