Compare commits
116 Commits
41760411a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cfa36e558 | |||
| c8102e62ee | |||
| d76b11430c | |||
| cb274545a3 | |||
| 6cd1349633 | |||
| bcecf2a81a | |||
| 621a48f011 | |||
| ce816af9e7 | |||
| 519cb25038 | |||
| f81d89980b | |||
| 7fc7661dad | |||
| 3b7982a3a3 | |||
| 697f0bf31e | |||
| b632a76d5a | |||
| d670067b89 | |||
| ac860207d9 | |||
| 03b26b5339 | |||
| 5a8d14eb4d | |||
| 79445bf879 | |||
| 4400fb5a74 | |||
| c6f46e097b | |||
| d2c0f68488 | |||
| 13e3a56fa6 | |||
| efea4a1384 | |||
| 73b5a5aefe | |||
| 03766d6b09 | |||
| e5f9331d30 | |||
| 6128e585b8 | |||
| 81174b78e4 | |||
| 70cc2cad81 | |||
| 6bc7b1da93 | |||
| eceee9c88d | |||
| 72eb3c2acf | |||
| eff69cfe52 | |||
| 63588ee3f1 | |||
| 8363406647 | |||
| df08692726 | |||
| 0030e0a932 | |||
| d95f212d2e | |||
| 0f7c4cc4d7 | |||
| a9641947ba | |||
| 63ff2cec77 | |||
| 6890d0570e | |||
| a20bbd7cdf | |||
| 7edd209abe | |||
| 2c39ebd985 | |||
| 1016e96b58 | |||
| b3c56864ac | |||
| 95e159ee5d | |||
| 5da44e1397 | |||
| b346be9431 | |||
| 229bde85e9 | |||
| dae6971112 | |||
| 3c2e936d56 | |||
| 9da21f7c89 | |||
| b6aae121bb | |||
| 24b53b9446 | |||
| 749eb956a5 | |||
| 5ce7cd43a4 | |||
| bb46a69eba | |||
| b018b427f6 | |||
| 99d92a6e90 | |||
| b712c19740 | |||
| 9c7ca86d86 | |||
| 6ac3407cc2 | |||
| 68b0351e9d | |||
| 74b0d80717 | |||
| baa6382fba | |||
| b9b67f685a | |||
| 64a5d0ae7e | |||
| 3e926f997b | |||
| 299a0abbc9 | |||
| 988bec521f | |||
| b93809946a | |||
| f4c9e20836 | |||
| 9fda192f0b | |||
| b8336f82c8 | |||
| a0fe76abc4 | |||
| 050841dd78 | |||
| 39a4952426 | |||
| 81137a4c5e | |||
| 954cd144b9 | |||
| 1c9f1d731e | |||
| 37be801270 | |||
| 52562bd8e6 | |||
| f8269f8f9d | |||
| 9e2674187c | |||
| 6482965edc | |||
| 5d162f3971 | |||
| 20910d5fda | |||
| db34c25bb4 | |||
| 2c10fdff62 | |||
| 33e2944fd8 | |||
| 2c375eef72 | |||
| 101e577035 | |||
| 2817273ba4 | |||
| f4be1a7f7d | |||
| 94fb7d3081 | |||
| f50f4baff0 | |||
| 1fb3511ed5 | |||
| 5f8223fbe1 | |||
| 02db62725d | |||
| a4329da607 | |||
| 25e48407c2 | |||
| 8889831615 | |||
| 121bb31f6e | |||
| 3dd565537f | |||
| eb34922da6 | |||
| 6156f87b05 | |||
| e82fcf46aa | |||
| f53ae05842 | |||
| 8c92f86842 | |||
| 93b68098ea | |||
| 99afba25c4 | |||
| 5618f07113 | |||
| 5694ed18c9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
/__pycache__/
|
/__pycache__/
|
||||||
secrets.py
|
secrets.py
|
||||||
|
config.json
|
||||||
/pymakr-test/
|
/pymakr-test/
|
||||||
.gitignore
|
.gitignore
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
402
README.md
402
README.md
@@ -1,22 +1,73 @@
|
|||||||
# 🌱 Auto Garden
|
# 🌱 Auto Garden
|
||||||
|
|
||||||
> Automated garden monitoring and control system using Raspberry Pi Pico W
|
> Automated climate control system using Raspberry Pi Pico W with web interface and scheduling
|
||||||
|
|
||||||
## Overview
|
## Recent Updates
|
||||||
|
|
||||||
This project automates garden monitoring using a Raspberry Pi Pico W with temperature sensors, Discord notifications, and planned expansion for humidity, soil moisture, and environmental controls.
|
- 🆕 **Immediate schedule application:** When resuming scheduling from hold mode, the system now instantly applies the current schedule targets (no delay).
|
||||||
|
- 🆕 **Aggressive memory management:** Garbage collection runs every 5 seconds for improved reliability.
|
||||||
|
- 🆕 **Manual hold settings:** `ac_target` and `heater_target` in `config.json` now only store your last manual hold settings, not schedule targets.
|
||||||
|
- 🆕 **NTP sync optimization:** NTP modules are loaded only when needed, saving RAM.
|
||||||
|
- 🆕 **Temperature validation:** Impossible sensor readings are ignored for safety.
|
||||||
|
- 🆕 **Improved config persistence:** All changes are saved and reloaded immediately.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ WiFi connectivity with auto-reconnect
|
- **Core Features**
|
||||||
|
- ✅ WiFi connectivity with auto-reconnect and static IP
|
||||||
- ✅ Inside/Outside temperature monitoring (DS18B20 sensors)
|
- ✅ Inside/Outside temperature monitoring (DS18B20 sensors)
|
||||||
- ✅ Discord notifications for temperature readings
|
- ✅ Web interface for monitoring and configuration <http://192.168.x.x>
|
||||||
- ✅ Separate alert channel for critical temperatures
|
- ✅ Discord notifications for all system events
|
||||||
- ✅ Temperature logging to CSV file
|
- ✅ Temperature logging to CSV file
|
||||||
- ✅ Configurable alert thresholds
|
- ✅ Configurable alert thresholds
|
||||||
- 🚧 Humidity monitoring (planned)
|
- ✅ Exception recovery (system won't crash permanently)
|
||||||
- 🚧 Soil moisture monitoring (planned)
|
- ✅ Graceful shutdown with Ctrl+C
|
||||||
- 🚧 Relay control for fans, AC, heaters (planned)
|
- ✅ **Aggressive garbage collection for stability**
|
||||||
|
|
||||||
|
- **Climate Control**
|
||||||
|
- ✅ Automated AC control with temperature swing logic
|
||||||
|
- ✅ Automated heater control with separate swing settings
|
||||||
|
- ✅ Short-cycle protection for both AC and heater
|
||||||
|
- ✅ Dual relay control via opto-coupler for 110V AC
|
||||||
|
- ✅ Mutual exclusion (AC and heater never run simultaneously)
|
||||||
|
- ✅ **Manual hold settings are preserved and not overwritten by schedules**
|
||||||
|
|
||||||
|
- **Scheduling System**
|
||||||
|
- ✅ 4 configurable time-based schedules per day
|
||||||
|
- ✅ Each schedule sets different AC/heater targets
|
||||||
|
- ✅ Automatic mode with schedule following
|
||||||
|
- ✅ Temporary hold mode (auto-resumes after configurable time)
|
||||||
|
- ✅ Permanent hold mode (manual control until restart)
|
||||||
|
- ✅ Schedule configuration persists through reboots
|
||||||
|
- ✅ Hold modes reset to Automatic on restart (safety feature)
|
||||||
|
- ✅ **Immediate schedule application after resuming from hold**
|
||||||
|
|
||||||
|
- **Web Interface**
|
||||||
|
- ✅ Real-time temperature display
|
||||||
|
- ✅ AC/Heater status indicators
|
||||||
|
- ✅ Manual temperature override
|
||||||
|
- ✅ Schedule editor (4 time slots)
|
||||||
|
- ✅ Mode control buttons (Automatic/Temp Hold/Perm Hold)
|
||||||
|
- ✅ Countdown timer for temporary holds
|
||||||
|
- ✅ Mobile-responsive design
|
||||||
|
- ✅ Auto-refresh dashboard (30 seconds)
|
||||||
|
- ✅ **Settings and schedule changes are reflected instantly**
|
||||||
|
|
||||||
|
## Configuration Notes
|
||||||
|
|
||||||
|
- **AC/Heater target settings:**
|
||||||
|
- `ac_target` and `heater_target` in `config.json` are updated whenever you use Temp Hold, Perm Hold, or when a schedule is applied.
|
||||||
|
- When schedules are active, these values are updated to match the current schedule’s targets.
|
||||||
|
- This ensures the config file always reflects the current operating temperatures, whether in hold mode or schedule mode.
|
||||||
|
|
||||||
|
- **Immediate schedule application:**
|
||||||
|
- When you click "Resume Scheduling," the system applies the current schedule targets instantly, so the dashboard updates without delay.
|
||||||
|
|
||||||
|
- **Memory management:**
|
||||||
|
- Garbage collection runs every 5 seconds to prevent memory fragmentation and crashes.
|
||||||
|
|
||||||
|
- **Sensor validation:**
|
||||||
|
- Temperatures outside the range -50°F to 150°F are ignored to prevent false readings.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -25,8 +76,9 @@ This project automates garden monitoring using a Raspberry Pi Pico W with temper
|
|||||||
**Required Components:**
|
**Required Components:**
|
||||||
|
|
||||||
- Raspberry Pi Pico W
|
- Raspberry Pi Pico W
|
||||||
- DS18B20 temperature sensors (waterproof recommended)
|
- 2x DS18B20 temperature sensors (waterproof recommended)
|
||||||
- 4.7kΩ resistor (pull-up for 1-Wire bus)
|
- 4.7kΩ resistor (pull-up for 1-Wire bus)
|
||||||
|
- 2-channel opto-coupler relay module (3.3V logic, 110V AC rated)
|
||||||
- Momentary button (optional, for easy reset)
|
- Momentary button (optional, for easy reset)
|
||||||
|
|
||||||
See the [Items Needed Wiki](https://gitea.rcs1.top/sickprodigy/Auto-Garden/wiki/Items-Needed-for-the-Project) for full parts list.
|
See the [Items Needed Wiki](https://gitea.rcs1.top/sickprodigy/Auto-Garden/wiki/Items-Needed-for-the-Project) for full parts list.
|
||||||
@@ -46,14 +98,39 @@ Yellow (Data) → GP10 (Inside) - Pin 14
|
|||||||
Add 4.7kΩ resistor between Data line and 3.3V
|
Add 4.7kΩ resistor between Data line and 3.3V
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**⚠️ Important:** The 4.7kΩ pull-up resistor is **required** for reliable 1-Wire communication.
|
||||||
|
|
||||||
|
**2-Channel Opto-Coupler Relay Module:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Low Voltage Side (Pico):
|
||||||
|
GP15 (Pin 20) → IN1 (AC Control Signal)
|
||||||
|
GP14 (Pin 19) → IN2 (Heater Control Signal)
|
||||||
|
3.3V (Pin 36) → VCC
|
||||||
|
GND → GND
|
||||||
|
|
||||||
|
High Voltage Side - Relay 1 (AC Unit):
|
||||||
|
NO (Normally Open) → AC Control Wire 1
|
||||||
|
COM (Common) → AC Control Wire 2
|
||||||
|
|
||||||
|
High Voltage Side - Relay 2 (Heater):
|
||||||
|
NO (Normally Open) → Heater Control Wire 1
|
||||||
|
COM (Common) → Heater Control Wire 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Most opto-coupler modules work with standard logic:
|
||||||
|
|
||||||
|
- `relay.on()` = relay energized (NO closes) = Device ON
|
||||||
|
- `relay.off()` = relay de-energized (NC closes) = Device OFF
|
||||||
|
|
||||||
|
If behavior is inverted, your module may be active LOW—see troubleshooting.
|
||||||
|
|
||||||
**Optional Reset Button:**
|
**Optional Reset Button:**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
RUN pin → Button → GND
|
RUN pin → Button → GND
|
||||||
```
|
```
|
||||||
|
|
||||||
Pressing button grounds RUN and resets the Pico.
|
|
||||||
|
|
||||||
### 3. Software Setup
|
### 3. Software Setup
|
||||||
|
|
||||||
**Install MicroPython:**
|
**Install MicroPython:**
|
||||||
@@ -64,7 +141,7 @@ Pressing button grounds RUN and resets the Pico.
|
|||||||
|
|
||||||
**IDE Setup:**
|
**IDE Setup:**
|
||||||
|
|
||||||
- Recommended: VS Code with [MicroPico extension](https://marketplace.visualstudio.com/items?itemName=paulober.pico-w-go) by paulober
|
- Recommended: VS Code with [MicroPico extension](https://marketplace.visualstudio.com/items?itemName=paulober.pico-w-go)
|
||||||
- Alternative: Thonny IDE
|
- Alternative: Thonny IDE
|
||||||
|
|
||||||
### 4. Configuration
|
### 4. Configuration
|
||||||
@@ -80,25 +157,51 @@ secrets = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configure sensors in `scripts/temperature_sensor.py`:**
|
**Sensor Configuration in `main.py`:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
# Sensor configuration
|
||||||
SENSOR_CONFIG = {
|
SENSOR_CONFIG = {
|
||||||
'inside': {
|
'inside': {
|
||||||
'pin': 10,
|
'pin': 10,
|
||||||
'label': 'Inside',
|
'label': 'Inside',
|
||||||
'alert_high': 85.0,
|
'alert_high': 80.0,
|
||||||
'alert_low': 32.0
|
'alert_low': 70.0
|
||||||
},
|
},
|
||||||
'outside': {
|
'outside': {
|
||||||
'pin': 11,
|
'pin': 11,
|
||||||
'label': 'Outside',
|
'label': 'Outside',
|
||||||
'alert_high': 100.0,
|
'alert_high': 85.0,
|
||||||
'alert_low': 20.0
|
'alert_low': 68.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Default Climate Settings (auto-saved to config.json):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Default config (created on first boot)
|
||||||
|
{
|
||||||
|
"ac_target": 77.0, # AC target temperature (°F)
|
||||||
|
"ac_swing": 1.0, # AC turns on at 78°F, off at 76°F
|
||||||
|
"heater_target": 72.0, # Heater target temperature (°F)
|
||||||
|
"heater_swing": 2.0, # Heater turns on at 70°F, off at 74°F
|
||||||
|
"temp_hold_duration": 3600, # Temporary hold lasts 1 hour (3600 seconds)
|
||||||
|
"schedule_enabled": true, # Schedules active by default
|
||||||
|
"schedules": [ # 4 time-based schedules
|
||||||
|
{
|
||||||
|
"time": "06:00",
|
||||||
|
"name": "Morning",
|
||||||
|
"ac_target": 75.0,
|
||||||
|
"heater_target": 72.0
|
||||||
|
},
|
||||||
|
# ... 3 more schedules
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All settings can be changed via the web interface and persist through reboots.
|
||||||
|
|
||||||
### 5. Upload & Run
|
### 5. Upload & Run
|
||||||
|
|
||||||
Upload all files to your Pico:
|
Upload all files to your Pico:
|
||||||
@@ -107,88 +210,296 @@ Upload all files to your Pico:
|
|||||||
/
|
/
|
||||||
├── main.py
|
├── main.py
|
||||||
├── secrets.py
|
├── secrets.py
|
||||||
|
├── config.json # Auto-generated on first boot
|
||||||
└── scripts/
|
└── scripts/
|
||||||
|
├── air_conditioning.py # AC/Heater controller classes
|
||||||
├── discord_webhook.py
|
├── discord_webhook.py
|
||||||
├── monitors.py
|
├── monitors.py
|
||||||
├── networking.py
|
├── networking.py
|
||||||
└── temperature_sensor.py
|
├── scheduler.py # Schedule system with hold timer
|
||||||
|
├── temperature_sensor.py
|
||||||
|
└── web_server.py # Web interface
|
||||||
```
|
```
|
||||||
|
|
||||||
The Pico will auto-start `main.py` on boot.
|
The Pico will auto-start `main.py` on boot and be accessible at **<http://192.168.x.x>**
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Auto-Garden/
|
Auto-Garden/
|
||||||
├── main.py # Entry point, sets up monitors
|
├── main.py # Entry point, configuration, system initialization
|
||||||
├── secrets.py # WiFi & Discord credentials (gitignored)
|
├── secrets.py # WiFi & Discord credentials (gitignored)
|
||||||
├── secrets.example.py # Template for secrets.py
|
├── secrets.example.py # Template for secrets.py
|
||||||
|
├── config.json # Persistent configuration (auto-generated)
|
||||||
└── scripts/
|
└── scripts/
|
||||||
|
├── air_conditioning.py # AC & Heater controllers with short-cycle protection
|
||||||
├── discord_webhook.py # Discord notification handling
|
├── discord_webhook.py # Discord notification handling
|
||||||
├── monitors.py # Monitor base class & implementations
|
├── monitors.py # Monitor base class & implementations
|
||||||
├── networking.py # WiFi connection management
|
├── networking.py # WiFi connection management
|
||||||
└── temperature_sensor.py # DS18B20 sensor interface & config
|
├── scheduler.py # Schedule system with temporary/permanent hold modes
|
||||||
|
├── temperature_sensor.py # DS18B20 sensor interface
|
||||||
|
└── web_server.py # Web interface for monitoring and control
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring Behavior
|
## How It Works
|
||||||
|
|
||||||
- **Every 10 seconds:** Check temperatures, send alerts if out of range
|
### Temperature Monitoring
|
||||||
- **Every 30 seconds:** Regular temperature reports to Discord + log to file
|
|
||||||
- **Every 5 seconds:** WiFi connection check with auto-reconnect
|
|
||||||
|
|
||||||
**Discord Channels:**
|
- **Every 10 seconds:** Check temperatures
|
||||||
|
- **Every 30 seconds:** Send temperature reports to Discord + log to CSV
|
||||||
|
- **Instant alerts:** High/low temperature warnings to separate Discord channel
|
||||||
|
|
||||||
- `discord_webhook_url`: Regular temperature updates, connection status
|
**Discord Notifications:**
|
||||||
|
|
||||||
|
- `discord_webhook_url`: Regular updates, status changes, system events
|
||||||
- `discord_alert_webhook_url`: Critical temperature alerts (Inside sensor only)
|
- `discord_alert_webhook_url`: Critical temperature alerts (Inside sensor only)
|
||||||
|
|
||||||
|
**Example Discord Messages:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
📊 Inside: 75.2°F | AC: OFF | Heater: OFF
|
||||||
|
📊 Outside: 68.5°F | AC: OFF | Heater: OFF
|
||||||
|
🔥 Inside temp HIGH: 81.0°F
|
||||||
|
Schedule 'Morning' applied - AC: 75°F, Heater: 72°F
|
||||||
|
⏸️ HOLD Mode - Manual override: AC: 77F +/- 1F | Heater: 72F +/- 2F
|
||||||
|
⏰ Temporary hold expired - Automatic mode resumed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Climate Control Logic
|
||||||
|
|
||||||
|
**AC Control:**
|
||||||
|
|
||||||
|
- Target: 77°F with 1°F swing
|
||||||
|
- AC turns **ON** when temp > 78°F (77 + 1)
|
||||||
|
- AC turns **OFF** when temp < 76°F (77 - 1)
|
||||||
|
- Between 76-78°F: maintains current state (dead band prevents cycling)
|
||||||
|
|
||||||
|
**Heater Control:**
|
||||||
|
|
||||||
|
- Target: 72°F with 2°F swing
|
||||||
|
- Heater turns **ON** when temp < 70°F (72 - 2)
|
||||||
|
- Heater turns **OFF** when temp > 74°F (72 + 2)
|
||||||
|
- Between 70-74°F: maintains current state
|
||||||
|
|
||||||
|
**Short-Cycle Protection:**
|
||||||
|
|
||||||
|
- Minimum run time: 30 seconds (prevents rapid off)
|
||||||
|
- Minimum off time: 5 seconds (protects compressor/heater elements)
|
||||||
|
- AC and heater never run simultaneously (mutual exclusion)
|
||||||
|
|
||||||
|
### Scheduling System
|
||||||
|
|
||||||
|
**Automatic Mode (Default):**
|
||||||
|
|
||||||
|
- Schedules apply at configured times (e.g., 06:00, 12:00, 18:00, 22:00)
|
||||||
|
- AC and heater targets update automatically
|
||||||
|
- System follows the most recent schedule until next one applies
|
||||||
|
|
||||||
|
**Temporary Hold Mode:**
|
||||||
|
|
||||||
|
- Activated by manual temperature changes or "⏸️ Temp Hold" button
|
||||||
|
- Pauses schedules for configurable duration (default: 1 hour)
|
||||||
|
- Web UI shows countdown timer: "45 min remaining"
|
||||||
|
- Auto-resumes to Automatic mode when timer expires
|
||||||
|
- Can be manually resumed with "▶️ Resume" button
|
||||||
|
|
||||||
|
**Permanent Hold Mode:**
|
||||||
|
|
||||||
|
- Activated by "🛑 Perm Hold" button
|
||||||
|
- Completely disables schedules (manual control only)
|
||||||
|
- Stays disabled until "▶️ Enable Schedules" clicked or Pico reboots
|
||||||
|
- No countdown timer
|
||||||
|
|
||||||
|
**Hold Reset on Boot:**
|
||||||
|
|
||||||
|
- All hold modes reset to Automatic on Pico restart/power cycle
|
||||||
|
- Safety feature ensures schedules always resume after power loss
|
||||||
|
- Temperature targets, swing values, and schedules persist
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
|
||||||
|
Access at **<http://192.168.x.x>**
|
||||||
|
|
||||||
|
**Dashboard (auto-refreshes every 30s):**
|
||||||
|
|
||||||
|
- Current inside/outside temperatures
|
||||||
|
- AC/Heater status indicators
|
||||||
|
- Next scheduled temperature change
|
||||||
|
- Current mode banner with countdown (if in Temporary Hold)
|
||||||
|
- Manual temperature override form
|
||||||
|
- Mode control buttons
|
||||||
|
|
||||||
|
**Schedule Editor:**
|
||||||
|
|
||||||
|
- Configure 4 time-based schedules
|
||||||
|
- Set time (HH:MM format), name, AC target, heater target for each
|
||||||
|
- Form validation (prevents heater > AC, invalid times)
|
||||||
|
- No auto-refresh (prevents losing edits)
|
||||||
|
|
||||||
|
**Mode Control:**
|
||||||
|
|
||||||
|
- **✅ Automatic Mode:** Schedules active, temps adjust based on time
|
||||||
|
- Buttons: [⏸️ Temp Hold] [🛑 Perm Hold]
|
||||||
|
- **⏸️ Temporary Hold:** Manual override with countdown timer
|
||||||
|
- Buttons: [▶️ Resume] [🛑 Perm Hold]
|
||||||
|
- **🛑 Permanent Hold:** Manual control only, schedules disabled
|
||||||
|
- Button: [▶️ Enable Schedules]
|
||||||
|
|
||||||
|
### WiFi Monitoring
|
||||||
|
|
||||||
|
- **Every 5 seconds:** Check WiFi connection
|
||||||
|
- **LED Indicator:**
|
||||||
|
- Solid ON: Connected
|
||||||
|
- Blinking: Reconnecting
|
||||||
|
- **Auto-reconnect:** Attempts every 60 seconds if disconnected
|
||||||
|
- **Static IP:** Always accessible at <http://192.168.x.x>
|
||||||
|
|
||||||
## Temperature Logs
|
## Temperature Logs
|
||||||
|
|
||||||
Logs are saved to `/temp_logs.csv` on the Pico:
|
Logs are saved to `/temp_logs.csv` on the Pico:
|
||||||
|
|
||||||
```csv
|
```csv
|
||||||
2025-11-05 14:30:00,Inside,28000012,72.50
|
2025-11-08 14:30:00,Inside,28000012,72.50
|
||||||
2025-11-05 14:30:00,Outside,28000034,45.30
|
2025-11-08 14:30:00,Outside,28000034,45.30
|
||||||
```
|
```
|
||||||
|
|
||||||
Format: `timestamp,location,sensor_id,temperature_f`
|
Format: `timestamp,location,sensor_id,temperature_f`
|
||||||
|
|
||||||
## Future Expansion
|
## Customization
|
||||||
|
|
||||||
### Planned Features
|
### Via Web Interface (Recommended)
|
||||||
|
|
||||||
- **Humidity Sensors:** DHT22 or SHT31 for air humidity monitoring
|
- Navigate to <http://192.168.x.x>
|
||||||
- **Soil Moisture:** Capacitive sensors for plant watering automation
|
- Adjust AC/Heater targets and swing values
|
||||||
- **Relay Control:** 3V-32VDC SSR relays for switching AC, fans, heaters
|
- Edit schedules (times, names, targets)
|
||||||
- **Smart Ventilation:** Auto-open windows when outside air is optimal
|
- Settings persist through reboots
|
||||||
- **Light Monitoring:** LDR or BH1750 for grow light automation
|
|
||||||
|
|
||||||
### Relay Wiring (Future)
|
### Via config.json
|
||||||
|
|
||||||
```text
|
```json
|
||||||
Pico 3.3V output → SSR relay input → High voltage device (120V/240V)
|
{
|
||||||
|
"ac_target": 77.0,
|
||||||
|
"ac_swing": 1.5, // Change swing range
|
||||||
|
"heater_target": 72.0,
|
||||||
|
"heater_swing": 2.5,
|
||||||
|
"temp_hold_duration": 7200, // 2 hours (in seconds)
|
||||||
|
"schedules": [ /* ... */ ]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use solid-state relays (SSR) rated for your voltage/current needs.
|
### Via main.py (Advanced)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Relay pins
|
||||||
|
ac_relay_pin = 15
|
||||||
|
heater_relay_pin = 14
|
||||||
|
|
||||||
|
# Sensor pins
|
||||||
|
SENSOR_CONFIG = {
|
||||||
|
'inside': {'pin': 10, ...},
|
||||||
|
'outside': {'pin': 11, ...}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Monitor intervals
|
||||||
|
check_interval=10 # Temperature check frequency
|
||||||
|
report_interval=30 # Discord report frequency
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety Notes
|
||||||
|
|
||||||
|
⚠️ **High Voltage Warning:**
|
||||||
|
|
||||||
|
- Opto-couplers isolate Pico from AC voltage
|
||||||
|
- Never connect GPIO directly to 110V AC
|
||||||
|
- Ensure relay module is rated for your voltage
|
||||||
|
- Test with multimeter before connecting AC loads
|
||||||
|
- Consider hiring licensed electrician if uncomfortable
|
||||||
|
|
||||||
|
**Compressor/Heater Protection:**
|
||||||
|
|
||||||
|
- Always use minimum run/off times
|
||||||
|
- Minimum 5s off time protects compressor bearings
|
||||||
|
- Minimum 30s run time prevents short cycling
|
||||||
|
- AC and heater mutual exclusion prevents simultaneous operation
|
||||||
|
|
||||||
|
**System Reliability:**
|
||||||
|
|
||||||
|
- Exception recovery prevents permanent crashes
|
||||||
|
- Graceful shutdown (Ctrl+C) safely turns off AC/heater
|
||||||
|
- Hold modes reset on reboot (schedules always resume)
|
||||||
|
- Static IP ensures web interface always accessible
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Web interface not loading:**
|
||||||
|
|
||||||
|
- Verify Pico is connected to WiFi (LED should be solid)
|
||||||
|
- Check static IP is <http://192.168.x.x>
|
||||||
|
- Look for "Web Interface: <http://192.168.x.x>" in serial console
|
||||||
|
- Try accessing from same WiFi network
|
||||||
|
|
||||||
**No temperature readings:**
|
**No temperature readings:**
|
||||||
|
|
||||||
- Check 4.7kΩ pull-up resistor is connected
|
- Check 4.7kΩ pull-up resistor between data line and 3.3V
|
||||||
- Verify sensor wiring (VDD to 3.3V, not 5V)
|
- Verify sensor wiring (VDD to 3.3V, not 5V)
|
||||||
- Check GPIO pin numbers in `SENSOR_CONFIG`
|
- Check GPIO pin numbers in `SENSOR_CONFIG`
|
||||||
|
- Run `sensor.scan_sensors()` in REPL to detect sensors
|
||||||
|
|
||||||
**WiFi not connecting:**
|
**WiFi not connecting:**
|
||||||
|
|
||||||
- Verify SSID/password in `secrets.py`
|
- Verify SSID/password in `secrets.py`
|
||||||
- Check 2.4GHz WiFi (Pico W doesn't support 5GHz)
|
- Check 2.4GHz WiFi (Pico W doesn't support 5GHz)
|
||||||
- Look for connection messages in serial console
|
- LED should be solid when connected
|
||||||
|
- Check serial console for connection status
|
||||||
|
|
||||||
**Discord messages not sending:**
|
**Discord messages not sending:**
|
||||||
|
|
||||||
- Verify webhook URLs are correct
|
- Verify webhook URLs in `secrets.py`
|
||||||
- Test webhooks with curl/Postman first
|
- Test webhooks with curl/Postman first
|
||||||
- Check Pico has internet access (ping test)
|
- Check Pico has internet access (ping test)
|
||||||
|
- Look for error messages in serial console
|
||||||
|
|
||||||
|
**AC/Heater not switching:**
|
||||||
|
|
||||||
|
- Verify relay pin numbers (default GP15/GP14)
|
||||||
|
- Test relay manually in REPL: `Pin(15, Pin.OUT).on()`
|
||||||
|
- Check if module is active LOW or active HIGH
|
||||||
|
- Ensure opto-coupler has 3.3V power
|
||||||
|
- Look for LED on relay module (should light when active)
|
||||||
|
- Check minimum run/off times haven't locked out switching
|
||||||
|
|
||||||
|
**AC/Heater behavior inverted:**
|
||||||
|
|
||||||
|
- Your opto-coupler is active LOW
|
||||||
|
- In `air_conditioning.py`, swap `relay.on()` and `relay.off()` calls in both ACController and HeaterController classes
|
||||||
|
|
||||||
|
**Schedules not applying:**
|
||||||
|
|
||||||
|
- Check NTP time sync: "Time synced with NTP server" in serial
|
||||||
|
- Verify schedule times in HH:MM format (24-hour)
|
||||||
|
- Ensure "✅ Automatic Mode" is active (not in hold)
|
||||||
|
- Check serial console for "Schedule Applied: [name]" messages
|
||||||
|
|
||||||
|
**Temporary hold not auto-resuming:**
|
||||||
|
|
||||||
|
- Check `temp_hold_duration` in config.json (in seconds)
|
||||||
|
- Look for "⏰ Temporary hold expired" in serial console
|
||||||
|
- ScheduleMonitor runs every 60 seconds, may take up to 1 min extra
|
||||||
|
- Verify timer countdown appears in web UI banner
|
||||||
|
|
||||||
|
**System keeps crashing:**
|
||||||
|
|
||||||
|
- Check for recent code changes
|
||||||
|
- Look for exception messages in serial console
|
||||||
|
- System should auto-recover from most errors (5s pause, then retry)
|
||||||
|
- If persistent, check memory usage with `gc.mem_free()`
|
||||||
|
|
||||||
|
**Config changes not saving:**
|
||||||
|
|
||||||
|
- Verify web form submissions redirect to dashboard/schedule page
|
||||||
|
- Check for "Settings persisted to disk" in serial console
|
||||||
|
- Ensure config.json has write permissions
|
||||||
|
- Try manual edit of config.json and reboot
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -204,7 +515,8 @@ MIT License - See LICENSE file for details
|
|||||||
- [MicroPython Documentation](https://docs.micropython.org/)
|
- [MicroPython Documentation](https://docs.micropython.org/)
|
||||||
- [DS18B20 Datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf)
|
- [DS18B20 Datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf)
|
||||||
- [Discord Webhooks Guide](https://discord.com/developers/docs/resources/webhook)
|
- [Discord Webhooks Guide](https://discord.com/developers/docs/resources/webhook)
|
||||||
|
- [1-Wire Protocol Guide](https://www.analog.com/en/technical-articles/guide-to-1wire-communication.html)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Note:** Always ensure proper electrical safety when working with high-voltage relays and AC power. Consult a licensed electrician if unsure.
|
**Note:** Always ensure proper electrical safety when working with high-voltage relays and AC power. Test thoroughly before leaving unattended.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from machine import Pin
|
from machine import Pin # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class ACController:
|
class ACController:
|
||||||
"""Control AC unit via opto-coupler relay."""
|
"""Control AC unit via opto-coupler relay."""
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import urequests as requests
|
# Minimal module-level state (only what we need)
|
||||||
from secrets import secrets
|
_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None}
|
||||||
|
# Cooldown after low-memory failures (epoch seconds)
|
||||||
|
_NEXT_ALLOWED_SEND_TS = 0
|
||||||
|
|
||||||
|
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:
|
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('"', '\\"')
|
s = s.replace('"', '\\"')
|
||||||
s = s.replace("\n", "\\n")
|
s = s.replace("\n", "\\n")
|
||||||
@@ -10,46 +27,110 @@ def _escape_json_str(s: str) -> str:
|
|||||||
s = s.replace("\t", "\\t")
|
s = s.replace("\t", "\\t")
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def send_discord_message(message, username="Auto Garden Bot", is_alert=False):
|
def send_discord_message(message, username="Auto Garden Bot", is_alert=False, debug: bool = False):
|
||||||
|
"""
|
||||||
|
Send Discord message with aggressive GC and low-memory guard to avoid ENOMEM.
|
||||||
|
When debug=True prints mem_free at important steps so you can see peak usage.
|
||||||
|
Returns True on success, False otherwise.
|
||||||
|
"""
|
||||||
|
global _NEXT_ALLOWED_SEND_TS
|
||||||
resp = None
|
resp = None
|
||||||
|
url = _get_webhook_url(is_alert=is_alert)
|
||||||
# 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')
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not url:
|
if not url:
|
||||||
# print("DEBUG: no webhook URL in secrets")
|
if debug: print("DBG: no webhook URL configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
url = url.strip().strip('\'"')
|
# Respect cooldown if we recently saw ENOMEM
|
||||||
|
try:
|
||||||
|
import time # type: ignore
|
||||||
|
now = time.time()
|
||||||
|
if _NEXT_ALLOWED_SEND_TS and now < _NEXT_ALLOWED_SEND_TS:
|
||||||
|
if debug: print("DBG: backing off until", _NEXT_ALLOWED_SEND_TS)
|
||||||
|
return False
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# build JSON by hand so emoji (and other unicode) are preserved as UTF-8 bytes
|
try:
|
||||||
content = _escape_json_str(message)
|
# Lightweight local imports and GC
|
||||||
user = _escape_json_str(username)
|
import gc # type: ignore
|
||||||
|
import time # type: ignore
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Quick mem check before importing urequests/SSL
|
||||||
|
mem = getattr(gc, "mem_free", lambda: None)()
|
||||||
|
# Require larger headroom based on device testing (adjust if you re-test)
|
||||||
|
if mem is not None and mem < 95000:
|
||||||
|
print("Discord send skipped: ENOMEM ({} bytes free)".format(mem))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Import urequests only when we plan to send
|
||||||
|
try:
|
||||||
|
import urequests as requests # type: ignore
|
||||||
|
except Exception as e:
|
||||||
|
print("Discord send failed: urequests import error:", e)
|
||||||
|
try:
|
||||||
|
_NEXT_ALLOWED_SEND_TS = time.time() + 60
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
if debug:
|
||||||
|
try: print("DBG: mem after import:", gc.mem_free() // 1024, "KB")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Build tiny payload
|
||||||
|
url = str(url).strip().strip('\'"')
|
||||||
|
content = _escape_json_str(str(message)[:140])
|
||||||
|
user = _escape_json_str(str(username)[:32])
|
||||||
body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8")
|
body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
||||||
|
|
||||||
resp = requests.post(url, data=body_bytes, headers=headers)
|
resp = requests.post(url, data=body_bytes, headers=headers)
|
||||||
|
|
||||||
status = getattr(resp, "status", getattr(resp, "status_code", None))
|
status = getattr(resp, "status", getattr(resp, "status_code", None))
|
||||||
|
return bool(status and 200 <= status < 300)
|
||||||
if status and 200 <= status < 300:
|
|
||||||
# print("Discord message sent")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# print(f"Discord webhook failed with status {status}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# print("Failed to send Discord message:", e)
|
print("Discord send failed:", e)
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
if resp:
|
|
||||||
try:
|
try:
|
||||||
|
if ("ENOMEM" in str(e)) or isinstance(e, MemoryError):
|
||||||
|
import time # type: ignore
|
||||||
|
_NEXT_ALLOWED_SEND_TS = time.time() + 60
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if resp:
|
||||||
resp.close()
|
resp.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
# remove local refs and unload heavy modules to free peak RAM (urequests, ussl/ssl)
|
||||||
|
try:
|
||||||
|
if 'resp' in locals(): del resp
|
||||||
|
if 'body_bytes' in locals(): del body_bytes
|
||||||
|
if 'content' in locals(): del content
|
||||||
|
if 'user' in locals(): del user
|
||||||
|
if 'headers' in locals(): del headers
|
||||||
|
if 'requests' in locals(): del requests
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
# remove urequests and SSL modules from module cache so their memory can be reclaimed
|
||||||
|
for m in ('urequests', 'ussl', 'ssl'):
|
||||||
|
if m in sys.modules:
|
||||||
|
try:
|
||||||
|
del sys.modules[m]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import gc # type: ignore
|
||||||
|
gc.collect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
68
Scripts/heating.py
Normal file
68
Scripts/heating.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from machine import Pin # type: ignore
|
||||||
|
import time # type: ignore
|
||||||
|
|
||||||
|
class HeaterController:
|
||||||
|
"""Control heater via opto-coupler relay."""
|
||||||
|
def __init__(self, relay_pin=16, min_run_time=300, min_off_time=180):
|
||||||
|
"""
|
||||||
|
relay_pin: GPIO pin connected to opto-coupler input
|
||||||
|
min_run_time: Minimum seconds heater must run before turning off (prevent short cycling)
|
||||||
|
min_off_time: Minimum seconds heater must be off before turning on (element protection)
|
||||||
|
"""
|
||||||
|
self.relay = Pin(relay_pin, Pin.OUT)
|
||||||
|
self.relay.off() # Start with heater off (relay normally open)
|
||||||
|
|
||||||
|
self.min_run_time = min_run_time
|
||||||
|
self.min_off_time = min_off_time
|
||||||
|
|
||||||
|
self.is_on = False
|
||||||
|
self.last_state_change = time.ticks_ms()
|
||||||
|
|
||||||
|
def turn_on(self):
|
||||||
|
"""Turn heater on if minimum off time has elapsed."""
|
||||||
|
if self.is_on:
|
||||||
|
return True # Already on
|
||||||
|
|
||||||
|
now = time.ticks_ms()
|
||||||
|
time_since_change = time.ticks_diff(now, self.last_state_change) / 1000
|
||||||
|
|
||||||
|
if time_since_change < self.min_off_time:
|
||||||
|
remaining = int(self.min_off_time - time_since_change)
|
||||||
|
print(f"Heater cooldown: {remaining}s remaining before can turn on")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.relay.on()
|
||||||
|
self.is_on = True
|
||||||
|
self.last_state_change = now
|
||||||
|
print("Heater turned ON")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def turn_off(self):
|
||||||
|
"""Turn heater off if minimum run time has elapsed."""
|
||||||
|
if not self.is_on:
|
||||||
|
return True # Already off
|
||||||
|
|
||||||
|
now = time.ticks_ms()
|
||||||
|
time_since_change = time.ticks_diff(now, self.last_state_change) / 1000
|
||||||
|
|
||||||
|
if time_since_change < self.min_run_time:
|
||||||
|
remaining = int(self.min_run_time - time_since_change)
|
||||||
|
print(f"Heater minimum runtime: {remaining}s remaining before can turn off")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.relay.off()
|
||||||
|
self.is_on = False
|
||||||
|
self.last_state_change = now
|
||||||
|
print("Heater turned OFF")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_state(self):
|
||||||
|
"""Return current heater state."""
|
||||||
|
return self.is_on
|
||||||
|
|
||||||
|
def force_off(self):
|
||||||
|
"""Emergency shut off (bypasses timers)."""
|
||||||
|
self.relay.off()
|
||||||
|
self.is_on = False
|
||||||
|
self.last_state_change = time.ticks_ms()
|
||||||
|
print("Heater FORCE OFF")
|
||||||
29
Scripts/memory_check.py
Normal file
29
Scripts/memory_check.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import gc # type: ignore
|
||||||
|
|
||||||
|
def check_memory_once():
|
||||||
|
"""One-time memory check (for startup diagnostics)."""
|
||||||
|
gc.collect()
|
||||||
|
free = gc.mem_free()
|
||||||
|
allocated = gc.mem_alloc()
|
||||||
|
total = free + allocated
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Startup Memory Check:")
|
||||||
|
print("="*50)
|
||||||
|
print("Total: {:.1f} KB".format(total / 1024))
|
||||||
|
print("Used: {:.1f} KB ({:.1f}%)".format(
|
||||||
|
allocated / 1024,
|
||||||
|
(allocated/total)*100
|
||||||
|
))
|
||||||
|
print("Free: {:.1f} KB ({:.1f}%)".format(
|
||||||
|
free / 1024,
|
||||||
|
(free/total)*100
|
||||||
|
))
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_kb': total / 1024,
|
||||||
|
'used_kb': allocated / 1024,
|
||||||
|
'free_kb': free / 1024,
|
||||||
|
'usage_percent': (allocated/total)*100
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import time
|
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
|
from scripts.temperature_sensor import TemperatureSensor
|
||||||
|
|
||||||
class Monitor:
|
class Monitor:
|
||||||
@@ -24,9 +24,24 @@ class Monitor:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class TemperatureMonitor(Monitor):
|
class TemperatureMonitor(Monitor):
|
||||||
"""Monitor temperature sensors and report to Discord."""
|
"""Monitor for tracking temperature readings and alerts."""
|
||||||
def __init__(self, sensor, label="Temp", check_interval=10, report_interval=30, alert_high=None, alert_low=None, log_file=None, send_alerts_to_separate_channel=False):
|
|
||||||
super().__init__(check_interval) # Check interval for temp reading
|
def __init__(self, sensor, label, check_interval=10, report_interval=60,
|
||||||
|
alert_high=None, alert_low=None, log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=False):
|
||||||
|
"""
|
||||||
|
Initialize temperature monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sensor: TemperatureSensor instance
|
||||||
|
label: Label for this sensor
|
||||||
|
check_interval: How often to check temp (seconds)
|
||||||
|
report_interval: How often to report/log temp (seconds)
|
||||||
|
alert_high: High temp threshold for alerts
|
||||||
|
alert_low: Low temp threshold for alerts
|
||||||
|
log_file: Path to CSV log file
|
||||||
|
send_alerts_to_separate_channel: Use separate Discord channel for alerts
|
||||||
|
"""
|
||||||
self.sensor = sensor
|
self.sensor = sensor
|
||||||
self.label = label
|
self.label = label
|
||||||
self.check_interval = check_interval
|
self.check_interval = check_interval
|
||||||
@@ -35,74 +50,123 @@ class TemperatureMonitor(Monitor):
|
|||||||
self.alert_low = alert_low
|
self.alert_low = alert_low
|
||||||
self.log_file = log_file
|
self.log_file = log_file
|
||||||
self.send_alerts_to_separate_channel = send_alerts_to_separate_channel
|
self.send_alerts_to_separate_channel = send_alerts_to_separate_channel
|
||||||
self.last_report_ms = 0
|
|
||||||
self.last_alert_state = None # Track if we were in alert state
|
self.last_check = 0
|
||||||
|
self.last_report = 0
|
||||||
|
self.alert_sent = False
|
||||||
|
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):
|
||||||
|
"""Check if it's time to run this monitor."""
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_check >= self.check_interval:
|
||||||
|
self.last_check = current_time
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Read sensors every check_interval, report/log every report_interval."""
|
"""Check temperature and handle alerts/logging."""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Read temperature
|
||||||
temps = self.sensor.read_all_temps(unit='F')
|
temps = self.sensor.read_all_temps(unit='F')
|
||||||
if not temps:
|
if not temps:
|
||||||
# print(f"No temperature readings available for {self.label}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.ticks_ms()
|
temp = list(temps.values())[0] # Get first temp reading
|
||||||
should_report = time.ticks_diff(now, self.last_report_ms) >= (self.report_interval * 1000)
|
|
||||||
|
|
||||||
for rom, temp in temps.items():
|
# ===== ADD THIS: Validate temperature is reasonable =====
|
||||||
sensor_id = rom.hex()[:8]
|
if temp < -50 or temp > 150: # Sanity check (outside normal range)
|
||||||
|
print("⚠️ Warning: {} sensor returned invalid temp: {:.1f}°F".format(self.label, temp))
|
||||||
|
return # Don't cache invalid reading
|
||||||
|
# ===== END: Validation =====
|
||||||
|
|
||||||
# Check if in alert state
|
# Cache the reading for web server (avoid blocking reads)
|
||||||
has_alert = False
|
self.last_temp = temp
|
||||||
alert_type = None
|
self.last_read_time = current_time
|
||||||
|
|
||||||
|
# Check for alerts
|
||||||
|
alert_condition = False
|
||||||
|
alert_message = ""
|
||||||
|
|
||||||
if self.alert_high and temp > self.alert_high:
|
if self.alert_high and temp > self.alert_high:
|
||||||
has_alert = True
|
alert_condition = True
|
||||||
alert_type = "HIGH"
|
alert_message = "⚠️ {} temperature HIGH: {:.1f}°F (threshold: {:.1f}°F)".format(
|
||||||
|
self.label, temp, self.alert_high
|
||||||
|
)
|
||||||
elif self.alert_low and temp < self.alert_low:
|
elif self.alert_low and temp < self.alert_low:
|
||||||
has_alert = True
|
alert_condition = True
|
||||||
alert_type = "LOW"
|
alert_message = "⚠️ {} temperature LOW: {:.1f}°F (threshold: {:.1f}°F)".format(
|
||||||
|
self.label, temp, self.alert_low
|
||||||
|
)
|
||||||
|
|
||||||
# Send alert immediately to alert channel (every check_interval, only if configured)
|
# Handle alert state changes
|
||||||
if has_alert and self.send_alerts_to_separate_channel:
|
if alert_condition and not self.alert_sent:
|
||||||
alert_msg = f"🚨 {self.label} Temperature: {temp:.1f}°F ⚠️ {alert_type} (threshold: {self.alert_high if alert_type == 'HIGH' else self.alert_low}°F)"
|
# Alert triggered
|
||||||
send_discord_message(alert_msg, is_alert=True)
|
self.alert_start_time = current_time
|
||||||
self.last_alert_state = True
|
print(alert_message)
|
||||||
|
|
||||||
# Send normal report at report_interval to regular channel (regardless of alert state)
|
# send alert (use module-level discord_webhook; set_config must be called in main)
|
||||||
if should_report:
|
if self.send_alerts_to_separate_channel:
|
||||||
temp_msg = f"🌡️ {self.label} Temperature: {temp:.1f}°F"
|
discord_webhook.send_discord_message(alert_message, is_alert=True)
|
||||||
|
else:
|
||||||
|
discord_webhook.send_discord_message(alert_message)
|
||||||
|
|
||||||
# Add alert indicator to regular report if in alert
|
self.alert_sent = True
|
||||||
if has_alert:
|
|
||||||
temp_msg += f" ⚠️ {alert_type}"
|
|
||||||
|
|
||||||
send_discord_message(temp_msg, is_alert=False)
|
elif not alert_condition and self.alert_sent:
|
||||||
|
# Alert resolved
|
||||||
|
duration = current_time - self.alert_start_time if self.alert_start_time else 0
|
||||||
|
|
||||||
# Send recovery message if we were in alert and now normal
|
# Format duration
|
||||||
if not has_alert and self.last_alert_state:
|
if duration >= 3600:
|
||||||
recovery_msg = f"✅ {self.label} Temperature back to normal: {temp:.1f}°F"
|
hours = int(duration / 3600)
|
||||||
send_discord_message(recovery_msg, is_alert=False)
|
minutes = int((duration % 3600) / 60)
|
||||||
self.last_alert_state = False
|
duration_str = "{}h {}m".format(hours, minutes)
|
||||||
|
elif duration >= 60:
|
||||||
|
minutes = int(duration / 60)
|
||||||
|
seconds = int(duration % 60)
|
||||||
|
duration_str = "{}m {}s".format(minutes, seconds)
|
||||||
|
else:
|
||||||
|
duration_str = "{}s".format(int(duration))
|
||||||
|
|
||||||
# Log to file at report_interval
|
recovery_message = "✅ {} temperature back to normal: {:.1f}°F (was out of range for {})".format(
|
||||||
if should_report and self.log_file:
|
self.label, temp, duration_str
|
||||||
self._log_temp(sensor_id, temp)
|
)
|
||||||
|
print(recovery_message)
|
||||||
|
|
||||||
# Update last report time
|
# send recovery message
|
||||||
if should_report:
|
if self.send_alerts_to_separate_channel:
|
||||||
self.last_report_ms = now
|
discord_webhook.send_discord_message(recovery_message, is_alert=True)
|
||||||
|
else:
|
||||||
|
discord_webhook.send_discord_message(recovery_message)
|
||||||
|
|
||||||
def _log_temp(self, sensor_id, temp):
|
self.alert_sent = False
|
||||||
"""Log temperature reading to file."""
|
self.alert_start_time = None
|
||||||
|
|
||||||
|
# Log temperature at report interval
|
||||||
|
if current_time - self.last_report >= self.report_interval:
|
||||||
|
self.last_report = current_time
|
||||||
|
self._log_temperature(temp)
|
||||||
|
|
||||||
|
def _log_temperature(self, temp):
|
||||||
|
"""Log temperature to CSV file."""
|
||||||
try:
|
try:
|
||||||
import time
|
# Get timestamp
|
||||||
timestamp = time.localtime()
|
t = time.localtime()
|
||||||
log_entry = f"{timestamp[0]}-{timestamp[1]:02d}-{timestamp[2]:02d} {timestamp[3]:02d}:{timestamp[4]:02d}:{timestamp[5]:02d},{self.label},{sensor_id},{temp:.2f}\n"
|
timestamp = "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
|
||||||
|
t[0], t[1], t[2], t[3], t[4], t[5]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append to log file
|
||||||
with open(self.log_file, 'a') as f:
|
with open(self.log_file, 'a') as f:
|
||||||
f.write(log_entry)
|
f.write("{},{},{:.2f}\n".format(
|
||||||
|
timestamp, self.label, temp
|
||||||
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error logging temperature: {e}")
|
print("Error logging temperature: {}".format(e))
|
||||||
|
|
||||||
class ACMonitor(Monitor):
|
class ACMonitor(Monitor):
|
||||||
def __init__(self, ac_controller, temp_sensor, target_temp=75.0, temp_swing=2.0, interval=30):
|
def __init__(self, ac_controller, temp_sensor, target_temp=75.0, temp_swing=2.0, interval=30):
|
||||||
@@ -130,31 +194,78 @@ class ACMonitor(Monitor):
|
|||||||
# Too hot, turn AC on
|
# Too hot, turn AC on
|
||||||
if self.ac.turn_on():
|
if self.ac.turn_on():
|
||||||
if not self.last_notified_state:
|
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
|
self.last_notified_state = True
|
||||||
|
|
||||||
elif current_temp < (self.target_temp - self.temp_swing):
|
elif current_temp < (self.target_temp - self.temp_swing):
|
||||||
# Cool enough, turn AC off
|
# Cool enough, turn AC off
|
||||||
if self.ac.turn_off():
|
if self.ac.turn_off():
|
||||||
if self.last_notified_state:
|
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
|
||||||
|
|
||||||
|
class HeaterMonitor(Monitor):
|
||||||
|
"""Monitor temperature and control heater automatically."""
|
||||||
|
def __init__(self, heater_controller, temp_sensor, target_temp=70.0, temp_swing=2.0, interval=30):
|
||||||
|
"""
|
||||||
|
heater_controller: HeaterController instance
|
||||||
|
temp_sensor: TemperatureSensor instance (inside temp)
|
||||||
|
target_temp: Target temperature in °F
|
||||||
|
temp_swing: Temperature swing allowed (prevents rapid cycling)
|
||||||
|
interval: Seconds between checks
|
||||||
|
"""
|
||||||
|
super().__init__(interval)
|
||||||
|
self.heater = heater_controller
|
||||||
|
self.sensor = temp_sensor
|
||||||
|
self.target_temp = target_temp
|
||||||
|
self.temp_swing = temp_swing
|
||||||
|
self.last_notified_state = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Check temperature and control heater."""
|
||||||
|
temps = self.sensor.read_all_temps(unit='F')
|
||||||
|
if not temps:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use first sensor reading (assuming single inside sensor)
|
||||||
|
current_temp = list(temps.values())[0]
|
||||||
|
|
||||||
|
# Heating logic with temperature swing
|
||||||
|
# Turn ON if: temp < target - temp_swing
|
||||||
|
# Turn OFF if: temp > target + temp_swing
|
||||||
|
|
||||||
|
if current_temp < (self.target_temp - self.temp_swing):
|
||||||
|
# Too cold, turn heater on
|
||||||
|
if self.heater.turn_on():
|
||||||
|
if not self.last_notified_state:
|
||||||
|
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:
|
||||||
|
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
|
self.last_notified_state = False
|
||||||
|
|
||||||
# Else: within temp_swing range, maintain current state
|
# Else: within temp_swing range, maintain current state
|
||||||
|
|
||||||
class WiFiMonitor(Monitor):
|
class WiFiMonitor(Monitor):
|
||||||
"""Monitor WiFi connection and handle reconnection."""
|
"""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)
|
super().__init__(interval)
|
||||||
self.wifi = wifi
|
self.wifi = wifi
|
||||||
self.led = led
|
self.led = led
|
||||||
self.reconnect_cooldown = reconnect_cooldown
|
self.reconnect_cooldown = reconnect_cooldown
|
||||||
self.last_reconnect_attempt = 0
|
self.last_reconnect_attempt = 0
|
||||||
self.was_connected = wifi.isconnected() if wifi else False
|
self.was_connected = wifi.isconnected() if wifi else False
|
||||||
|
self.config = config
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Check WiFi status, blink LED, attempt reconnect if needed."""
|
"""Check WiFi status, blink LED, attempt reconnect if needed."""
|
||||||
import network
|
import network # type: ignore
|
||||||
from scripts.networking import connect_wifi
|
from scripts.networking import connect_wifi
|
||||||
|
|
||||||
is_connected = self.wifi.isconnected() if self.wifi else False
|
is_connected = self.wifi.isconnected() if self.wifi else False
|
||||||
@@ -170,10 +281,10 @@ class WiFiMonitor(Monitor):
|
|||||||
if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000):
|
if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000):
|
||||||
self.last_reconnect_attempt = now
|
self.last_reconnect_attempt = now
|
||||||
# print("Attempting WiFi reconnect...")
|
# 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():
|
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
|
self.was_connected = True
|
||||||
else:
|
else:
|
||||||
# Slow blink when connected
|
# Slow blink when connected
|
||||||
@@ -183,7 +294,7 @@ class WiFiMonitor(Monitor):
|
|||||||
|
|
||||||
# Notify if connection was just restored
|
# Notify if connection was just restored
|
||||||
if not self.was_connected:
|
if not self.was_connected:
|
||||||
send_discord_message("WiFi connection restored 🔄")
|
discord_webhook.send_discord_message("WiFi connection restored 🔄")
|
||||||
self.was_connected = True
|
self.was_connected = True
|
||||||
|
|
||||||
def run_monitors(monitors):
|
def run_monitors(monitors):
|
||||||
|
|||||||
@@ -1,37 +1,132 @@
|
|||||||
import network
|
import network # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
from secrets import secrets
|
|
||||||
|
|
||||||
RECONNECT_COOLDOWN_MS = 60000 # 60 seconds
|
def connect_wifi(led=None, max_retries=3, timeout=20, config=None):
|
||||||
|
|
||||||
def connect_wifi(led=None, timeout=10):
|
|
||||||
"""
|
"""
|
||||||
Connect to WiFi using secrets['ssid'] / secrets['password'].
|
Connect to WiFi using credentials from provided config dict.
|
||||||
If `led` (machine.Pin) is provided, pulse it once on successful connect.
|
|
||||||
Returns the WLAN object or None on failure.
|
|
||||||
"""
|
|
||||||
wifi = network.WLAN(network.STA_IF)
|
|
||||||
wifi.active(True)
|
|
||||||
|
|
||||||
# print("Connecting to WiFi...", end="")
|
Args:
|
||||||
wifi.connect(secrets['ssid'], secrets['password'])
|
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...")
|
||||||
|
try:
|
||||||
|
wlan.deinit()
|
||||||
|
time.sleep(2)
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.active(True)
|
||||||
|
time.sleep(1)
|
||||||
|
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("Already connected to WiFi")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f'Connecting to WiFi SSID: {ssid} (attempt {attempt}/{max_retries})...')
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 for connection with timeout
|
||||||
max_wait = timeout
|
wait_time = 0
|
||||||
while max_wait > 0:
|
while wait_time < timeout:
|
||||||
if wifi.status() < 0 or wifi.status() >= 3:
|
if wlan.isconnected():
|
||||||
break
|
break
|
||||||
max_wait -= 1
|
|
||||||
# print(".", end="")
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
if wifi.isconnected():
|
|
||||||
# print("\nConnected! Network config:", wifi.ifconfig())
|
|
||||||
if led:
|
if led:
|
||||||
led.on()
|
try:
|
||||||
time.sleep(1)
|
# some LED wrappers use toggle(), others use on/off
|
||||||
led.off()
|
if hasattr(led, "toggle"):
|
||||||
return wifi
|
led.toggle()
|
||||||
else:
|
else:
|
||||||
# print("\nConnection failed!")
|
# 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:
|
||||||
|
try:
|
||||||
|
# prefer available method names
|
||||||
|
if hasattr(led, "off"):
|
||||||
|
led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Success feedback
|
||||||
|
if led:
|
||||||
|
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
|
||||||
227
Scripts/scheduler.py
Normal file
227
Scripts/scheduler.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import time # type: ignore
|
||||||
|
|
||||||
|
class ScheduleMonitor:
|
||||||
|
"""Monitor that checks and applies temperature schedules."""
|
||||||
|
|
||||||
|
def __init__(self, ac_monitor, heater_monitor, config, interval=60):
|
||||||
|
"""
|
||||||
|
Initialize schedule monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ac_monitor: ACMonitor instance
|
||||||
|
heater_monitor: HeaterMonitor instance
|
||||||
|
config: Configuration dict with schedules
|
||||||
|
interval: How often to check schedule (seconds)
|
||||||
|
"""
|
||||||
|
self.ac_monitor = ac_monitor
|
||||||
|
self.heater_monitor = heater_monitor
|
||||||
|
self.config = config
|
||||||
|
self.interval = interval
|
||||||
|
self.last_check = 0
|
||||||
|
self.current_schedule = None
|
||||||
|
self.last_applied_schedule = None
|
||||||
|
self.temp_hold_duration = config.get('temp_hold_duration', 3600) # Use config value, default 1 hour
|
||||||
|
|
||||||
|
def should_run(self):
|
||||||
|
"""Check if it's time to run this monitor."""
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_check >= self.interval:
|
||||||
|
self.last_check = current_time
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_time(self, time_str):
|
||||||
|
"""Convert time string 'HH:MM' to minutes since midnight."""
|
||||||
|
try:
|
||||||
|
parts = time_str.split(':')
|
||||||
|
hours = int(parts[0])
|
||||||
|
minutes = int(parts[1])
|
||||||
|
return hours * 60 + minutes
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_current_minutes(self):
|
||||||
|
"""Get current time in minutes since midnight."""
|
||||||
|
t = time.localtime()
|
||||||
|
return t[3] * 60 + t[4] # hours * 60 + minutes
|
||||||
|
|
||||||
|
def _find_active_schedule(self):
|
||||||
|
"""Find which schedule should be active right now."""
|
||||||
|
if not self.config.get('schedule_enabled', False):
|
||||||
|
# Schedule is disabled (HOLD mode)
|
||||||
|
return None
|
||||||
|
|
||||||
|
schedules = self.config.get('schedules', [])
|
||||||
|
if not schedules:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_minutes = self._get_current_minutes()
|
||||||
|
|
||||||
|
# Sort schedules by time
|
||||||
|
sorted_schedules = []
|
||||||
|
for schedule in schedules:
|
||||||
|
schedule_minutes = self._parse_time(schedule['time'])
|
||||||
|
if schedule_minutes is not None:
|
||||||
|
sorted_schedules.append((schedule_minutes, schedule))
|
||||||
|
|
||||||
|
sorted_schedules.sort()
|
||||||
|
|
||||||
|
# Find the most recent schedule that has passed
|
||||||
|
active_schedule = None
|
||||||
|
for schedule_minutes, schedule in sorted_schedules:
|
||||||
|
if current_minutes >= schedule_minutes:
|
||||||
|
active_schedule = schedule
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no schedule found (before first schedule), use last schedule from yesterday
|
||||||
|
if active_schedule is None and sorted_schedules:
|
||||||
|
active_schedule = sorted_schedules[-1][1]
|
||||||
|
|
||||||
|
return active_schedule
|
||||||
|
|
||||||
|
def _apply_schedule(self, schedule):
|
||||||
|
"""Apply a schedule's settings to the monitors."""
|
||||||
|
if not schedule:
|
||||||
|
return
|
||||||
|
|
||||||
|
schedule_id = schedule.get('time', '') + schedule.get('name', '')
|
||||||
|
if schedule_id == self.last_applied_schedule:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
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 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')
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Schedule Applied: {}".format(schedule_name))
|
||||||
|
print("="*50)
|
||||||
|
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 (use discord_webhook if available)
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Check if schedule needs to be updated."""
|
||||||
|
|
||||||
|
# ===== START: Check if temporary hold has expired =====
|
||||||
|
if not self.config.get('schedule_enabled', False) and not self.config.get('permanent_hold', False):
|
||||||
|
# In temporary hold mode - check if timer expired
|
||||||
|
temp_hold_start = self.config.get('temp_hold_start_time') # <-- READ FROM CONFIG NOW
|
||||||
|
|
||||||
|
if temp_hold_start is not None:
|
||||||
|
elapsed = time.time() - temp_hold_start
|
||||||
|
|
||||||
|
if elapsed >= self.temp_hold_duration:
|
||||||
|
# Timer expired - resume automatic scheduling
|
||||||
|
print("⏰ Temporary hold expired - resuming schedule")
|
||||||
|
self.config['schedule_enabled'] = True
|
||||||
|
self.config['temp_hold_start_time'] = None
|
||||||
|
|
||||||
|
# Save updated config
|
||||||
|
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:
|
||||||
|
# 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
|
||||||
|
active_schedule = self._find_active_schedule()
|
||||||
|
if active_schedule:
|
||||||
|
self._apply_schedule(active_schedule)
|
||||||
|
|
||||||
|
def reload_config(self, new_config):
|
||||||
|
"""Reload configuration (called when settings are updated via web)."""
|
||||||
|
self.config = new_config
|
||||||
|
self.last_applied_schedule = None # Force re-application
|
||||||
|
print("Schedule configuration reloaded")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import machine
|
import machine # type: ignore
|
||||||
import onewire
|
import onewire # type: ignore
|
||||||
import ds18x20
|
import ds18x20 # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class TemperatureSensor:
|
class TemperatureSensor:
|
||||||
def __init__(self, pin=10, label=None):
|
def __init__(self, pin=10, label=None):
|
||||||
@@ -63,26 +63,3 @@ class TemperatureSensor:
|
|||||||
print(f'Error reading temperatures: {e}')
|
print(f'Error reading temperatures: {e}')
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Sensor configuration registry
|
|
||||||
SENSOR_CONFIG = {
|
|
||||||
'inside': {
|
|
||||||
'pin': 10,
|
|
||||||
'label': 'Inside',
|
|
||||||
'alert_high': 80.0,
|
|
||||||
'alert_low': 70.0
|
|
||||||
},
|
|
||||||
'outside': {
|
|
||||||
'pin': 11,
|
|
||||||
'label': 'Outside',
|
|
||||||
'alert_high': 85.0,
|
|
||||||
'alert_low': 68.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_configured_sensors():
|
|
||||||
"""Return dictionary of configured sensor instances."""
|
|
||||||
sensors = {}
|
|
||||||
for key, config in SENSOR_CONFIG.items():
|
|
||||||
sensors[key] = TemperatureSensor(pin=config['pin'], label=config['label'])
|
|
||||||
return sensors
|
|
||||||
1866
Scripts/web_server.py
Normal file
1866
Scripts/web_server.py
Normal file
File diff suppressed because it is too large
Load Diff
45
config.json.Example
Normal file
45
config.json.Example
Normal 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
|
||||||
|
}
|
||||||
512
main.py
512
main.py
@@ -1,65 +1,487 @@
|
|||||||
from machine import Pin
|
from machine import Pin, RTC # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
from scripts.networking import connect_wifi
|
import network # type: ignore
|
||||||
from scripts.discord_webhook import send_discord_message
|
import json
|
||||||
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, run_monitors
|
import gc # type: ignore # ADD THIS - for garbage collection
|
||||||
from scripts.temperature_sensor import get_configured_sensors, SENSOR_CONFIG
|
import sys
|
||||||
from scripts.air_conditioning import ACController
|
|
||||||
|
|
||||||
# Initialize pins (LED light onboard)
|
# Initialize pins (LED light onboard)
|
||||||
led = Pin("LED", Pin.OUT)
|
led = Pin("LED", Pin.OUT)
|
||||||
led.low()
|
led.low()
|
||||||
|
|
||||||
# Connect to WiFi
|
# Hard reset WiFi interface before connecting
|
||||||
wifi = connect_wifi(led)
|
print("Initializing WiFi...")
|
||||||
|
try:
|
||||||
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
wlan.deinit()
|
||||||
|
time.sleep(2)
|
||||||
|
print("WiFi interface reset complete")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WiFi reset warning: {e}")
|
||||||
|
|
||||||
# Send startup message if connected
|
# Import after WiFi reset
|
||||||
|
from scripts.networking import connect_wifi
|
||||||
|
|
||||||
|
# ===== NEW: NTP Sync Function (imports locally) =====
|
||||||
|
def sync_ntp_time(timezone_offset):
|
||||||
|
"""
|
||||||
|
Sync time with NTP server (imports modules locally to save RAM).
|
||||||
|
Returns True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Import ONLY when needed (freed by GC after function ends)
|
||||||
|
import socket # type: ignore
|
||||||
|
import struct # type: ignore
|
||||||
|
|
||||||
|
NTP_DELTA = 2208988800
|
||||||
|
host = "pool.ntp.org"
|
||||||
|
NTP_QUERY = bytearray(48)
|
||||||
|
NTP_QUERY[0] = 0x1B
|
||||||
|
|
||||||
|
# Create socket with timeout
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(3.0) # 3-second timeout
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr = socket.getaddrinfo(host, 123)[0][-1]
|
||||||
|
s.sendto(NTP_QUERY, addr)
|
||||||
|
msg = s.recv(48)
|
||||||
|
val = struct.unpack("!I", msg[40:44])[0]
|
||||||
|
utc_timestamp = val - NTP_DELTA
|
||||||
|
|
||||||
|
# Apply timezone offset
|
||||||
|
local_timestamp = utc_timestamp + (timezone_offset * 3600)
|
||||||
|
|
||||||
|
# Set RTC with local time
|
||||||
|
tm = time.gmtime(local_timestamp)
|
||||||
|
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("NTP sync failed: {}".format(e))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Force garbage collection to free socket/struct modules
|
||||||
|
gc.collect()
|
||||||
|
# ===== END: NTP Sync Function =====
|
||||||
|
|
||||||
|
# ===== START: Configuration Loading =====
|
||||||
|
# Load saved settings from config.json file on Pico
|
||||||
|
def load_config():
|
||||||
|
"""Load configuration from config.json file."""
|
||||||
|
try:
|
||||||
|
with open('config.json', 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
print("Loaded saved settings from config.json")
|
||||||
|
return config
|
||||||
|
except:
|
||||||
|
# If file doesn't exist or is corrupted, create default config
|
||||||
|
print("No saved config found, creating default config.json...")
|
||||||
|
|
||||||
|
default_config = {
|
||||||
|
'static_ip': '192.168.86.43',
|
||||||
|
'subnet': '255.255.255.0',
|
||||||
|
'gateway': '192.168.86.1',
|
||||||
|
'dns': '192.168.86.1',
|
||||||
|
'timezone_offset': -6, # Timezone offset from UTC (CST=-6, EST=-5, MST=-7, PST=-8, add 1 for DST)
|
||||||
|
'ac_target': 75.0, # Default AC target temp
|
||||||
|
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
|
||||||
|
'heater_target': 72.0, # Default heater target temp
|
||||||
|
'heater_swing': 2.0, # Default heater tolerance (+/- degrees)
|
||||||
|
'temp_hold_duration': 3600, # Default hold duration in seconds (1 hour)
|
||||||
|
'temp_hold_start_time': None, # No hold active at startup
|
||||||
|
'schedules': [ # Default 4 schedules
|
||||||
|
{
|
||||||
|
'time': '06:00',
|
||||||
|
'name': 'Morning',
|
||||||
|
'ac_target': 75.0,
|
||||||
|
'heater_target': 72.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'time': '12:00',
|
||||||
|
'name': 'Midday',
|
||||||
|
'ac_target': 75.0,
|
||||||
|
'heater_target': 72.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'time': '18:00',
|
||||||
|
'name': 'Evening',
|
||||||
|
'ac_target': 75.0,
|
||||||
|
'heater_target': 72.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'time': '22:00',
|
||||||
|
'name': 'Night',
|
||||||
|
'ac_target': 75.0,
|
||||||
|
'heater_target': 72.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'schedule_enabled': True, # Schedules disabled by default (user can enable via web)
|
||||||
|
'permanent_hold': False # Permanent hold disabled by default
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== START: Save default config to file =====
|
||||||
|
try:
|
||||||
|
with open('config.json', 'w') as f:
|
||||||
|
json.dump(default_config, f)
|
||||||
|
print("✅ Default config.json created successfully with 4 sample schedules")
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠️ Warning: Could not create config.json: {}".format(e))
|
||||||
|
print(" (Program will continue with defaults in memory)")
|
||||||
|
# ===== END: Save default config to file =====
|
||||||
|
|
||||||
|
return default_config
|
||||||
|
|
||||||
|
# global variables for Discord webhook status
|
||||||
|
discord_sent = False
|
||||||
|
discord_send_attempts = 0
|
||||||
|
pending_discord_message = None
|
||||||
|
|
||||||
|
# Load configuration from file
|
||||||
|
config = load_config()
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# ===== START: Reset hold modes on startup =====
|
||||||
|
# Always reset to automatic mode on boot (don't persist hold states)
|
||||||
|
if 'schedule_enabled' in config:
|
||||||
|
config['schedule_enabled'] = True # Always enable schedules on boot
|
||||||
|
if 'permanent_hold' in config:
|
||||||
|
config['permanent_hold'] = False # Always clear permanent hold on boot
|
||||||
|
if 'temp_hold_start_time' in config:
|
||||||
|
config['temp_hold_start_time'] = None # Clear temp hold start time
|
||||||
|
|
||||||
|
# Save the reset config immediately
|
||||||
|
try:
|
||||||
|
with open('config.json', 'w') as f:
|
||||||
|
json.dump(config, f)
|
||||||
|
print("✅ Hold modes reset - Automatic mode active")
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠️ Warning: Could not save config reset: {}".format(e))
|
||||||
|
# ===== END: Reset hold modes on startup =====
|
||||||
|
# ===== END: Configuration Loading =====
|
||||||
|
|
||||||
|
# ===== START: WiFi Connection =====
|
||||||
|
# Connect to WiFi using credentials from config.json
|
||||||
|
wifi = connect_wifi(led, config=config)
|
||||||
|
|
||||||
|
# Set static IP and print WiFi details
|
||||||
if wifi and wifi.isconnected():
|
if wifi and wifi.isconnected():
|
||||||
send_discord_message("Pico W online and connected ✅")
|
# Get static IP settings from config
|
||||||
|
static_ip = config.get('static_ip')
|
||||||
|
subnet = config.get('subnet')
|
||||||
|
gateway = config.get('gateway')
|
||||||
|
dns = config.get('dns')
|
||||||
|
|
||||||
# Get configured sensors
|
# Apply static IP configuration
|
||||||
sensors = get_configured_sensors() # returns a dict, e.g. {'inside': ..., 'outside': ...}
|
wifi.ifconfig((static_ip, subnet, gateway, dns))
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Print WiFi details for debugging
|
||||||
|
ifconfig = wifi.ifconfig()
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("WiFi Connected Successfully!")
|
||||||
|
print("="*50)
|
||||||
|
print(f"IP Address: {ifconfig[0]}")
|
||||||
|
print(f"Subnet Mask: {ifconfig[1]}")
|
||||||
|
print(f"Gateway: {ifconfig[2]}")
|
||||||
|
print(f"DNS Server: {ifconfig[3]}")
|
||||||
|
print(f"Web Interface: http://{ifconfig[0]}")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
|
||||||
|
# Try sending Discord webhook NOW, before creating other objects
|
||||||
|
gc.collect()
|
||||||
|
ram_free = gc.mem_free()
|
||||||
|
print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
|
||||||
|
mem_ok = ram_free > 95000
|
||||||
|
if mem_ok:
|
||||||
|
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
|
||||||
|
if ok:
|
||||||
|
print("Discord startup notification sent")
|
||||||
|
discord_sent = True
|
||||||
|
else:
|
||||||
|
print("Discord startup notification failed")
|
||||||
|
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
|
||||||
|
discord_send_attempts = 1
|
||||||
|
else:
|
||||||
|
print("Not enough memory for Discord startup notification")
|
||||||
|
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
|
||||||
|
discord_send_attempts = 1
|
||||||
|
|
||||||
|
# ===== Moved to later so discord could fire off startup message hopefully =====
|
||||||
|
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
|
||||||
|
from scripts.temperature_sensor import TemperatureSensor
|
||||||
|
from scripts.air_conditioning import ACController
|
||||||
|
from scripts.heating import HeaterController
|
||||||
|
from scripts.web_server import TempWebServer
|
||||||
|
from scripts.scheduler import ScheduleMonitor
|
||||||
|
from scripts.memory_check import check_memory_once
|
||||||
|
|
||||||
|
# Start web server early so page can load even if time sync is slow
|
||||||
|
web_server = TempWebServer(port=80)
|
||||||
|
web_server.start()
|
||||||
|
|
||||||
|
# ===== INITIAL NTP SYNC (using function) =====
|
||||||
|
ntp_synced = False
|
||||||
|
try:
|
||||||
|
ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
|
||||||
|
if ntp_synced:
|
||||||
|
print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
|
||||||
|
else:
|
||||||
|
print("Initial NTP sync failed, will retry in background...")
|
||||||
|
except Exception as e:
|
||||||
|
print("Initial NTP sync error: {}".format(e))
|
||||||
|
# ===== END: INITIAL NTP SYNC =====
|
||||||
|
|
||||||
|
else:
|
||||||
|
# WiFi connection failed
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("WiFi Connection Failed!")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
# ===== END: WiFi Connection =====
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ===== START: Sensor Configuration =====
|
||||||
|
# Define all temperature sensors and their alert thresholds
|
||||||
|
SENSOR_CONFIG = {
|
||||||
|
'inside': {
|
||||||
|
'pin': 10, # GPIO pin for DS18B20 sensor
|
||||||
|
'label': 'Inside', # Display name
|
||||||
|
'alert_high': 80.0, # Send alert if temp > 80°F
|
||||||
|
'alert_low': 70.0 # Send alert if temp < 70°F
|
||||||
|
},
|
||||||
|
'outside': {
|
||||||
|
'pin': 11, # GPIO pin for DS18B20 sensor
|
||||||
|
'label': 'Outside', # Display name
|
||||||
|
'alert_high': 85.0, # Send alert if temp > 85°F
|
||||||
|
'alert_low': 68.0 # Send alert if temp < 68°F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize sensors based on configuration
|
||||||
|
def get_configured_sensors():
|
||||||
|
"""Return dictionary of configured sensor instances."""
|
||||||
|
sensors = {}
|
||||||
|
for key, config in SENSOR_CONFIG.items():
|
||||||
|
sensors[key] = TemperatureSensor(pin=config['pin'], label=config['label'])
|
||||||
|
return sensors
|
||||||
|
|
||||||
|
# Create sensor instances
|
||||||
|
sensors = get_configured_sensors()
|
||||||
|
# ===== END: Sensor Configuration =====
|
||||||
|
|
||||||
|
# ===== START: AC Controller Setup =====
|
||||||
|
# Set up air conditioning relay controller
|
||||||
ac_controller = ACController(
|
ac_controller = ACController(
|
||||||
relay_pin=15,
|
relay_pin=15, # GPIO pin connected to AC relay
|
||||||
min_run_time=30,
|
min_run_time=30, # Minimum seconds AC must run before turning off
|
||||||
min_off_time=5
|
min_off_time=5 # Minimum seconds AC must be off before turning on again
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create AC monitor (automatically controls AC based on temperature)
|
||||||
ac_monitor = ACMonitor(
|
ac_monitor = ACMonitor(
|
||||||
ac_controller=ac_controller,
|
ac_controller=ac_controller,
|
||||||
temp_sensor=sensors['inside'], # <-- This is your inside temperature sensor
|
temp_sensor=sensors['inside'], # Use inside sensor for AC control
|
||||||
target_temp=77.0,
|
target_temp=config['ac_target'], # Target temp from config.json
|
||||||
temp_swing=1.0,
|
temp_swing=config['ac_swing'], # Tolerance (+/- degrees)
|
||||||
interval=30
|
interval=30 # Check temperature every 30 seconds
|
||||||
|
)
|
||||||
|
# ===== END: AC Controller Setup =====
|
||||||
|
|
||||||
|
# ===== START: Heater Controller Setup =====
|
||||||
|
# Set up heating relay controller
|
||||||
|
heater_controller = HeaterController(
|
||||||
|
relay_pin=16, # GPIO pin connected to heater relay
|
||||||
|
min_run_time=30, # Minimum seconds heater must run before turning off
|
||||||
|
min_off_time=5 # Minimum seconds heater must be off before turning on again
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up monitors
|
# Create heater monitor (automatically controls heater based on temperature)
|
||||||
monitors = [
|
heater_monitor = HeaterMonitor(
|
||||||
WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60),
|
heater_controller=heater_controller,
|
||||||
ac_monitor,
|
temp_sensor=sensors['inside'], # Use inside sensor for heater control
|
||||||
]
|
target_temp=config['heater_target'], # Target temp from config.json
|
||||||
|
temp_swing=config['heater_swing'], # Tolerance (+/- degrees)
|
||||||
# Add temperature monitors from config
|
interval=30 # Check temperature every 30 seconds
|
||||||
for key, sensor in sensors.items():
|
|
||||||
config = SENSOR_CONFIG[key]
|
|
||||||
|
|
||||||
# Inside temp alerts go to separate channel
|
|
||||||
send_to_alert_channel = (key == 'inside')
|
|
||||||
|
|
||||||
monitors.append(
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensor,
|
|
||||||
label=config['label'],
|
|
||||||
check_interval=10, # Check temp every 10 seconds
|
|
||||||
report_interval=30, # Report/log every 30 seconds
|
|
||||||
alert_high=config['alert_high'],
|
|
||||||
alert_low=config['alert_low'],
|
|
||||||
log_file="/temp_logs.csv",
|
|
||||||
send_alerts_to_separate_channel=send_to_alert_channel
|
|
||||||
)
|
)
|
||||||
|
# ===== END: Heater Controller Setup =====
|
||||||
|
|
||||||
|
# ===== START: Schedule Monitor Setup =====
|
||||||
|
# Create schedule monitor (automatically changes temp targets based on time of day)
|
||||||
|
schedule_monitor = ScheduleMonitor(
|
||||||
|
ac_monitor=ac_monitor, # Pass AC monitor to control
|
||||||
|
heater_monitor=heater_monitor, # Pass heater monitor to control
|
||||||
|
config=config, # Pass config with schedules
|
||||||
|
interval=60 # Check schedule every 60 seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
# Main monitoring loop
|
# ===== APPLY ACTIVE SCHEDULE IMMEDIATELY ON STARTUP =====
|
||||||
|
if config.get('schedule_enabled', False):
|
||||||
|
try:
|
||||||
|
# Find and apply the current active schedule
|
||||||
|
active_schedule = schedule_monitor._find_active_schedule()
|
||||||
|
if active_schedule:
|
||||||
|
schedule_monitor._apply_schedule(active_schedule)
|
||||||
|
print("✅ Active schedule applied on startup: {}".format(
|
||||||
|
active_schedule.get('name', 'Unnamed')
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
print("ℹ️ No active schedule found (using manual targets)")
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠️ Warning: Could not apply startup schedule: {}".format(e))
|
||||||
|
else:
|
||||||
|
print("ℹ️ Schedules disabled - using manual targets")
|
||||||
|
# ===== END: APPLY ACTIVE SCHEDULE ON STARTUP =====
|
||||||
|
# ===== END: Schedule Monitor Setup =====
|
||||||
|
|
||||||
|
# ===== START: Print Current Settings =====
|
||||||
|
# Display loaded configuration for debugging
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Current Climate Control Settings:")
|
||||||
|
print("="*50)
|
||||||
|
print(f"AC Target: {config['ac_target']}°F ± {config['ac_swing']}°F")
|
||||||
|
print(f"Heater Target: {config['heater_target']}°F ± {config['heater_swing']}°F")
|
||||||
|
print(f"Schedule: {'Enabled' if config.get('schedule_enabled') else 'Disabled'}")
|
||||||
|
if config.get('schedules'):
|
||||||
|
print(f"Schedules: {len(config.get('schedules', []))} configured")
|
||||||
|
print("="*50 + "\n")
|
||||||
|
# ===== END: Print Current Settings =====
|
||||||
|
|
||||||
|
# ===== START: Startup Memory Check =====
|
||||||
|
# Check memory usage after all imports and initialization
|
||||||
|
check_memory_once()
|
||||||
|
# ===== END: Startup Memory Check =====
|
||||||
|
|
||||||
|
print("Starting monitoring loop...")
|
||||||
|
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
|
||||||
|
last_ntp_sync = time.time() # Track when we last synced
|
||||||
|
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
run_monitors(monitors)
|
|
||||||
|
# ===== START: Main Loop =====
|
||||||
|
# Main monitoring loop (runs forever until Ctrl+C)
|
||||||
|
last_monitor_run = {
|
||||||
|
"wifi": 0,
|
||||||
|
"schedule": 0,
|
||||||
|
"ac": 0,
|
||||||
|
"heater": 0,
|
||||||
|
"inside_temp": 0,
|
||||||
|
"outside_temp": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# WiFi monitor every 5 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["wifi"] >= 5:
|
||||||
|
from scripts.monitors import WiFiMonitor
|
||||||
|
wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
|
||||||
|
try:
|
||||||
|
wifi_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("WiFiMonitor error:", e)
|
||||||
|
del wifi_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["wifi"] = now
|
||||||
|
|
||||||
|
# Schedule monitor every 60 seconds (persistent)
|
||||||
|
if now - last_monitor_run["schedule"] >= 60:
|
||||||
|
try:
|
||||||
|
schedule_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ScheduleMonitor error:", e)
|
||||||
|
last_monitor_run["schedule"] = now
|
||||||
|
|
||||||
|
# AC monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["ac"] >= 30:
|
||||||
|
try:
|
||||||
|
ac_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ACMonitor error:", e)
|
||||||
|
last_monitor_run["ac"] = now
|
||||||
|
|
||||||
|
# Heater monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["heater"] >= 30:
|
||||||
|
try:
|
||||||
|
heater_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("HeaterMonitor error:", e)
|
||||||
|
last_monitor_run["heater"] = now
|
||||||
|
|
||||||
|
# Inside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["inside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
inside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['inside'],
|
||||||
|
label=SENSOR_CONFIG['inside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['inside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['inside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=True
|
||||||
|
)
|
||||||
|
inside_monitor.run()
|
||||||
|
del inside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["inside_temp"] = now
|
||||||
|
|
||||||
|
# Outside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["outside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
outside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['outside'],
|
||||||
|
label=SENSOR_CONFIG['outside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['outside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['outside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=False
|
||||||
|
)
|
||||||
|
outside_monitor.run()
|
||||||
|
del outside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["outside_temp"] = now
|
||||||
|
|
||||||
|
# Web requests (keep web server loaded if needed)
|
||||||
|
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
# ===== END: Main Loop =====
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Shutting down gracefully...")
|
||||||
|
print("="*50)
|
||||||
|
try:
|
||||||
|
print("Turning off AC...")
|
||||||
|
ac_controller.turn_off()
|
||||||
|
except Exception as e:
|
||||||
|
print("AC shutdown error:", e)
|
||||||
|
try:
|
||||||
|
print("Turning off heater...")
|
||||||
|
heater_controller.turn_off()
|
||||||
|
except Exception as e:
|
||||||
|
print("Heater shutdown error:", e)
|
||||||
|
try:
|
||||||
|
print("Turning off LED...")
|
||||||
|
led.low()
|
||||||
|
except Exception as e:
|
||||||
|
print("LED shutdown error:", e)
|
||||||
|
print("Shutdown complete!")
|
||||||
|
print("="*50)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user