对于许多技术爱好者和家庭用户而言,搭建一套离网太阳能系统不仅能节约能源,更是一种充满乐趣的挑战。本文将详细记录一套12V太阳能系统的完整构建过程,涵盖从核心组件的选型,到利用ESP8266和INA219实现高精度电压监控与智能控制,再到最终攻克技术难点,实现市电与太阳能的“无缝”毫秒级切换,确保台式电脑等关键设备在电源切换时永不关机。

第一部分:搭建一套稳定可靠的12V离网太阳能系统

一个成功的太阳能系统始于正确的组件匹配。我们的目标是构建一个以12V磷酸铁锂电池为核心,能够为日常负载提供稳定电力的系统。

核心组件选型与匹配性分析

经过仔细考量,我们选择了以下核心组件,并对其匹配性进行了分析:

组件 规格 匹配性分析 (针对12V系统) 价格 结论
锂电池 12V 150Ah 撼电宁德磷酸铁锂 总容量达到1800Wh (12V × 150Ah),储能充足,足以应对更长的阴雨天或更高的设备负载。宁德电芯性能稳定,安全性高。 688(京东,建议拼多多更便宜) 基础适配(配合48v太阳能板,建议用两块12v电池串联 最大化利用)
太阳能板 48v 550W (适配12V系统) 在理想光照下,550W太阳能板为12V系统充电的理论最大电流约为16.7A (550W ÷ 12V),这个电流值在控制器的处理范围之内。(2185*1090双面) 379(拼多多) 基础适配
MPPT控制器 80A (锂电模式) 控制器的额定电流为80A,远大于太阳能板产生的16.7A,留有充足的冗余,可避免过载,并为未来升级太阳能板留下空间。核心在于其支持锂电池充电模式,这与铅酸电池的充电曲线完全不同,能确保电池安全和寿命。 53(拼多多) 电流适配
逆变器 1000W 纯正弦波 1000W的功率足以带动大部分家用电器。纯正弦波输出对于电脑、音响等精密电子设备至关重要,能提供与市电质量相当的稳定电流,避免设备损坏。 140(淘宝) 负载适配

第二部分:ESP8266与INA219:实现高精度智能电压监控

在太阳能系统中,精确监控电池电压是保护电池、防止过充过放的关键。虽然ESP8266自带ADC功能,但其线性度和精度无法满足锂电池管理的苛刻要求。因此,我们引入高精度I2C传感模块INA219来解决此问题。

面临的挑战与创新解决方案

  1. 挑战:如何在大电流(可能高达几十安培)的主回路中精确测量电压,同时不引入额外的电阻和压降?如果按标准方式串联INA219,其内置的采样电阻会发热并影响系统效率。
  2. 解决方案:妙用INA219,将其变为纯粹的“并联电压表”。我们完全绕过其电流测量功能,仅利用其高精度的电压测量单元。这个方案两全其美:
    • 获得了INA219带来的高精度、高线性度的电压读数。
    • 完全不干扰主电源回路,避免了在大电流路径中引入任何压降和损耗。

硬件连接指南 (仅作电压表)

这种接法极其简单,原理就像用万用表测电压一样。

  1. 主回路保持不变:电池和逆变器之间的粗壮主电缆保持原样,完全不受影响。
  2. 改造INA219模块:用一根短导线,将INA219模块上的 Vin-Vin+ 这两个端子直接短接。这会让模块测量的“分流电压”恒定为0,电流读数也永远是0。
  3. 并联到电池
    • 从电池的正极,引出一根细导线连接到INA219的 Vin- (或Vin+)。
    • 从电池的负极,引出一根细导线连接到INA219的 GND 端子。
  4. 连接ESP8266与INA219 (信号线)
    • INA219 VCC -> ESP8266 3.3V
    • INA219 GND -> ESP8266 GND
    • INA219 SCL -> ESP8266 D1
    • INA219 SDA -> ESP8266 D2
  5. 连接继电器
  6. ESP8266供电
  • Vin:INA219的 Vin- (或Vin+) 连接vin即可(因为第3步 Vin- (或Vin+)连接电源正极,并且这正极继电器一直闭合端的一根线可以继电器断开也能检测电压,vin可以承受3-15v电压,磷酸铁锂电压一般10-13.8v
  • Gnd:负极上面第3步已经连接

 

完整代码 (v5.1 - INA219 仅电压版)

这是一个基于 ESP8266 微控制器和 INA219 高精度电压电流传感器的多功能智能继电器项目。它通过一个现代化的 Web 界面提供全面的监控和控制,并通过一套智能化的保护和通知逻辑,确保您的设备(如电池、太阳能系统等)安全、高效地运行。


功能列表 (Features)

  1. 高精度电压监测 (High-Precision Voltage Monitoring)

    • 使用 Adafruit INA219 传感器,以并联方式精确测量电压,避免了传统方案中串联电阻带来的压降问题。

  2. 全功能 Web 用户界面 (Full-Featured Web UI)

    • 实时数据显示:清晰展示当前电池电压和继电器状态。

    • 手动控制:可随时通过网页按钮开启或关闭继电器。

    • 参数在线设置:无需重新编程,直接在网页上设定高压开启、电压警告和低压关闭的阈值。

    • 系统信息:显示设备的IP地址、芯片ID和剩余内存。

    • 固件在线更新 (OTA):通过网页上传 .bin 文件即可更新设备固件,极其方便。

  3. 智能电压保护逻辑 (Intelligent Voltage Protection Logic)

    • 高压自动开启:当电压恢复到设定的高压阈值时,自动开启继电器。

    • 低压自动关闭:当电压低于设定的低压阈值时,自动关闭继电器以保护电池,防止过放。

    • 低压锁定机制:触发低压保护后,设备会进入1小时的锁定状态,防止电压在临界点反复开关。

    • 电压警告阈值:在达到低压关断之前,提供一个中间的警告阈值,用于提前通知。

  4. 高级通知系统 (Advanced Notification System)

    • 通知方式可选:您可以轻松选择通过 电子邮件 (Email) 或 IFTTT Webhook (手机推送) 接收通知。

    • 智能防误报

      • 趋势判断:只有当电压呈下降趋势时才会触发警告,完美过滤掉因充电导致的电压上升性误报。

      • 迟滞机制:引入电压缓冲带,有效防止因负载变化等原因导致的电压在阈值附近小幅波动而产生的重复通知。

    • 通知冷却机制

      • 无论是“电压警告”还是“低压关断”,在1小时内最多只会发送1次通知,避免信息轰炸,让通知更有效。

  5. 参数持久化存储 (Persistent Parameter Storage)

    • 所有电压阈值设置都会被保存在 ESP8266 的 EEPROM 中,即使设备断电或重启,您的设置也不会丢失。

  6. 便捷的网络访问 (Convenient Network Access)

    • 集成了 mDNS 服务,您可以在局域网内通过 http://esp8266-smart-relay.local (或您自定义的设备名) 这样友好的地址来访问设备,而无需记住IP地址。


所需库文件 (Required Libraries)

要成功编译和运行此项目,您需要确保在您的 Arduino IDE 中安装了以下第三方库。

注意:ESP8266 的核心库(如 ESP8266WiFi.h, ESP8266WebServer.h, EEPROM.h 等)在您安装 ESP8266 开发板支持时已自动包含,无需额外安装。

您需要手动安装的库如下:

  1. Adafruit INA219

    • 作者: Adafruit

    • 功能: 用于与 INA219 高精度电压/电流传感器进行 I2C 通信,读取电压值。

  2. NTPClient

    • 作者: Fabrice Weinberg

    • 功能: 用于从网络时间协议(NTP)服务器获取并同步标准网络时间,主要用于在 Web 界面上显示准确的当前时间。

  3. ESP Mail Client

    • 作者: Mobizt

    • 功能: 一个功能强大的邮件发送客户端库。如果您选择使用邮件通知功能,此库是必需的。请确保安装最新版本,因为我们的最终代码是适配其最新API的。

 

// =================================================================================================
// ==   ESP8266 智能继电器 & INA219 v6.16 (Inverted Relay Logic)                  ==
// =================================================================================================
// 描述: 此版本为最终功能版。根据用户要求,将继电器的控制逻辑进行了反转,
//       以适配低电平触发的继电器模块。
//       - `setRelay(true)` (开启) -> `digitalWrite(LOW)`
//       - `setRelay(false)` (关闭) -> `digitalWrite(HIGH)`
//       所有其他功能保持不变。
// =================================================================================================

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <Wire.h>
#include <Adafruit_INA219.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <EEPROM.h>
#include <time.h>
#include <ESP_Mail_Client.h>
#include <WiFiClientSecure.h>
#include <LittleFS.h>

// ============== 用户配置 ==============
const char* ssid       = "yang1234";
const char* password   = "y123456789";
const char* deviceName = "esp8266-smart-relay";
const int WEB_SERVER_PORT = 80;

// ============== 邮件与IFTTT通知配置 (请二选一或全部注释) ==============
#define ENABLE_EMAIL_NOTIFICATION 
// #define ENABLE_IFTTT_NOTIFICATION 

#if defined(ENABLE_EMAIL_NOTIFICATION)
  const char* SMTP_HOST             = "smtp.qq.com";
  const int   SMTP_PORT             = 465;
  const char* AUTHOR_EMAIL          = "xxx@qq.com";
  const char* AUTHOR_PASSWORD       = "xxxx";
  const char* RECIPIENT_EMAIL       = "xxx@qq.com";
  const char* NTP_SERVERS           = "ntp.aliyun.com, pool.ntp.org, time.nist.gov";
  const int   GMT_OFFSET            = 8;
  const int   DAYLIGHT_OFFSET       = 0;
#elif defined(ENABLE_IFTTT_NOTIFICATION)
  const char* IFTTT_API_KEY         = "YOUR_IFTTT_KEY_HERE";
  const char* IFTTT_EVENT_NAME      = "esp_voltage_alert";
#endif


// ============== 硬件引脚配置 ==============
const int RELAY_PIN   = 14; // D5
const int I2C_SDA_PIN = 4;  // D2
const int I2C_SCL_PIN = 5;  // D1

// ============== 时间与定时任务配置 ==============
const char* WEB_UI_NTP_SERVER = "ntp.aliyun.com";
const long  WEB_UI_GMT_OFFSET_SEC = 8 * 3600;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, WEB_UI_NTP_SERVER, WEB_UI_GMT_OFFSET_SEC);

// ============== EEPROM 存储地址定义 ==============
const int EEPROM_SIZE = 64; 
const int ADDR_MAGIC_NUM   = 0;
const int ADDR_LOW_V       = 2;
const int ADDR_HIGH_V      = 6;
const int ADDR_WARN_V      = 10;
const uint16_t EEPROM_MAGIC_NUMBER = 0x5A1D;

// ============== 全局对象与变量 ==============
ESP8266WebServer server(WEB_SERVER_PORT);
Adafruit_INA219 ina219;
SMTPSession smtp;

bool relayState = false, ina219_ok = false;
float busVoltage = 0;
float lowVoltageThreshold = 10.5, highVoltageThreshold = 12.8, warningVoltageThreshold = 11.5;
bool isLockedOut = false;
bool isVoltageWarning = false;
unsigned long lockoutStartTime = 0;
const unsigned long LOCKOUT_DURATION_MS = 3600000;

float lastBusVoltage = 0;
const float WARNING_HYSTERESIS_V = 0.5;

const unsigned long NOTIFICATION_COOLDOWN_MS = 3600000;
unsigned long lastWarningNoticeTime = 0;
unsigned long lastLockoutNoticeTime = 0;

// --- 图表数据配置 ---
const char* VLOG_FILE_PATH = "/vlog.dat";
const int DATA_POINTS = 1440;
const unsigned long DATA_INTERVAL_MS = 60000;
int historyIndex = 0;

// --- 低电压重启 ---
unsigned long lowVoltageRebootTimer = 0;
const float REBOOT_VOLTAGE_THRESHOLD = 10.0;
const unsigned long REBOOT_TIMER_DURATION_MS = 3600000;

// --- CPU使用率估算 ---
volatile unsigned long cpuIdleCounter = 0;
unsigned long lastCpuMeasureTime = 0;
float cpuUsage = 0.0;
const unsigned long CPU_MEASURE_INTERVAL_MS = 1000;
unsigned long maxIdleCountsPerSecond = 0;

void ICACHE_RAM_ATTR countCpuIdle() {
  cpuIdleCounter++;
}


// =====================================================
// ============== 网页 (HTML+CSS+JS) v6.15 ==============
// =====================================================
const char MAIN_HTML_PART1[] PROGMEM = R"HTML(
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP8266 智能继电器</title><script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script><style>:root{--bg-color:#111827;--card-color:#1f2937;--text-color:#d1d5db;--accent-color:#38bdf8;--green-color:#22c55e;--red-color:#ef4444;--warning-color:#f59e0b;--muted-text:#9ca3af}body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";background-color:var(--bg-color);color:var(--text-color);margin:0;padding:15px;display:flex;justify-content:center}h1,h2,h4{margin-top:0;color:#fff;text-align:center}h2{border-top:1px solid #374151;padding-top:15px;margin-top:20px}.container{width:100%;max-width:500px}.card{background-color:var(--card-color);border-radius:12px;padding:20px;margin-bottom:15px;box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06)}.chart-card{padding:20px 0 10px 0;}.chart-card h2{padding:0 20px 15px;margin:0;border:none;}.data-box{text-align:center;padding:10px}.data-box .val{font-size:2.5em;font-weight:700;color:var(--accent-color);line-height:1.2;transition:color .3s ease}.data-box .unit{color:var(--muted-text)}.btn{width:100%;padding:15px;font-size:1.2em;font-weight:bold;border:none;border-radius:8px;cursor:pointer;transition:background-color .2s ease}.btn.on{background-color:var(--green-color);color:#fff}.btn.off{background-color:var(--red-color);color:#fff}.status-light{width:12px;height:12px;border-radius:50%;display:inline-block;margin-right:8px;background-color:#6b7280}.status-light.on{background-color:var(--green-color)}.input-group{display:flex;align-items:center;gap:10px;margin-bottom:10px}.input-group label{flex-basis:120px;flex-shrink:0}input[type=number]{width:100%;padding:8px;background-color:#374151;border:1px solid #4b5563;border-radius:6px;color:var(--text-color);font-size:1em}.btn-save{padding:10px 15px;background-color:var(--accent-color);color:#fff;border:none;border-radius:6px;cursor:pointer}#sysinfo{font-size:.8em;color:var(--muted-text);word-break:break-all}#lockoutStatus{color:var(--red-color);text-align:center;margin-bottom:10px;font-weight:bold;}</style></head><body><div class="container"><h1>ESP8266 智能继电器</h1><p style="text-align:center;color:var(--muted-text);">当前时间: <span id="currentTime">--:--:--</span></p><div class="card"><div class="data-box"><div>电池电压</div><div class="val" id="v">--</div><div class="unit">V</div></div></div><div class="card"><h2>手动控制</h2><div id="lockoutStatus" style="display:none;"></div><p><span id="relayStatusLight" class="status-light"></span>继电器状态: <strong id="relayStatusText">读取中...</strong></p><button id="relayBtn" class="btn">读取中...</button></div><div class="card"><h2>参数设置</h2><div class="input-group"><label for="highV">高压开启 (V)</label><input type="number" id="highV" step="0.1"></div><div class="input-group"><label for="warnV">电压警告 (V)</label><input type="number" id="warnV" step="0.1"></div><div class="input-group"><label for="lowV">低压关闭 (V)</label><input type="number" id="lowV" step="0.1"></div><div style="text-align:right;margin-top:10px;"><button class="btn-save" onclick="saveSettings()">保存设置</button></div></div><div class="card"><h2>系统信息与更新</h2><div id="sysinfo">加载中...</div><h4>固件更新 (OTA)</h4><div id="otaUi"><form id="otaForm" method="POST" action="/update" enctype="multipart/form-data"><input type="file" name="update" accept=".bin,.bin.gz" required><button type="submit" class="btn-save" style="margin-top:10px;">上传并更新</button></form></div><div id="otaStatus"></div></div><div class="card chart-card"><h2>24小时电压曲线</h2><div id="voltageChart" style="width: 100%; height: 250px;"></div></div></div>
)HTML";

const char MAIN_HTML_PART2[] PROGMEM = R"HTML(
<script>
var echartInstance;
var latestDeviceTimeStr = "--:--:--";
const DATA_INTERVAL_MIN = 1;

function $(s){return document.getElementById(s)}
function fetchJson(url,options){return fetch(url,options).then(r=>{if(!r.ok)throw new Error('Network error');return r.json()})}
function updateStatus(data){
  const relayOn=data.relay;
  $('relayStatusText').textContent=relayOn?'已开启':'已关闭';
  $('relayStatusText').style.color=relayOn?'var(--green-color)':'var(--red-color)';
  $('relayStatusLight').className=relayOn?'status-light on':'status-light';
  $('relayBtn').textContent=relayOn?'关闭继电器':'开启继电器';
  $('relayBtn').className=relayOn?'btn off':'btn on';
  if(data.lockout){$('lockoutStatus').style.display='block';$('lockoutStatus').textContent='电压警告中!剩余 '+data.lockout_rem+' 分钟可自动恢复。'}else{$('lockoutStatus').style.display='none';}
}
function fetchData(){fetchJson('/getData').then(data=>{
  $('v').textContent=data.voltage.toFixed(2);
  $('v').style.color=data.voltage_warning?'var(--warning-color)':'var(--accent-color)';
  $('currentTime').textContent=data.time;
  latestDeviceTimeStr = data.time;
  updateStatus(data);
})}
function fetchInitialState(){fetchJson('/getStatus').then(data=>{
  updateStatus(data);
  $('lowV').value=data.low_v;
  $('warnV').value=data.warn_v;
  $('highV').value=data.high_v;
  $('sysinfo').innerHTML=`IPv4: ${data.ip}<br>芯片ID: ${data.chip_id}<br>CPU繁忙度: ${data.cpu_usage}%<br>内存(RAM): ${data.free_heap} / ${data.total_heap} KB<br>存储(Flash): ${data.fs_free} / ${data.fs_total} KB`;
})}
async function initChart(){
  const chartDom = $('voltageChart');
  echartInstance = echarts.init(chartDom);
  echartInstance.showLoading({ text: '正在加载历史数据...' });
  
  try {
    const chartData = await fetchJson('/getChartData');
    
    const option={
      tooltip:{
        trigger:'axis',
        formatter: function(params){
          const point = params[0];
          if (point.value === null || point.value === '-') return null;
          const dataIndex = point.dataIndex;
          const voltage = parseFloat(point.value).toFixed(2);
          const minutesAgo = (chartData.data.length - 1 - dataIndex) * DATA_INTERVAL_MIN;
          
          let now = new Date();
          if (latestDeviceTimeStr !== "--:--:--") {
            const timeParts = latestDeviceTimeStr.split(':');
            now.setHours(timeParts[0], timeParts[1], timeParts[2]);
          }
          now.setMinutes(now.getMinutes() - minutesAgo);
          
          const historicalDate = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')}`;
          const historicalTime = `${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
          
          return `${historicalDate} ${historicalTime}<br/>电压: <strong>${voltage} V</strong>`;
        }
      },
      grid:{left:'8%',right:'4%',bottom:'10%',containLabel:false},
      xAxis:{type:'category',boundaryGap:false,data:chartData.labels,axisLine:{lineStyle:{color:'var(--muted-text)'}},axisTick:{show:false}},
      yAxis:{type:'value',name:'电压 (V)',nameTextStyle:{color:'var(--muted-text)',padding:[0,0,0,35]},min:'dataMin',max:'dataMax',axisLine:{show:true,lineStyle:{color:'var(--muted-text)'}},splitLine:{lineStyle:{color:'rgba(255,255,255,0.1)'}}},
      series:[{name:'电压',type:'line',smooth:true,connectNulls:false,data:chartData.data,symbol:'none',areaStyle:{color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:'rgba(56,189,248,0.5)'},{offset:1,color:'rgba(56,189,248,0.1)'}])},lineStyle:{color:'var(--accent-color)'}}]
    };
    
    echartInstance.hideLoading();
    echartInstance.setOption(option);
    window.addEventListener('resize',()=>echartInstance.resize());
  } catch (error) {
    echartInstance.hideLoading();
    console.error("Failed to load chart data:", error);
    echartInstance.setOption({title: {text: '图表数据加载失败', left: 'center', top: 'center', textStyle: {color: 'var(--red-color)'}}});
  }
}
async function updateChart(){
  if(!echartInstance){return;}
  try {
    fetchInitialState();
    const chartData=await fetchJson('/getChartData');
    echartInstance.setOption({xAxis:{data:chartData.labels},series:[{data:chartData.data}]});
  } catch (error) {
    console.error("Failed to update chart data:", error);
  }
}
function saveSettings(){
  const lowV=$('lowV').value;
  const warnV=$('warnV').value;
  const highV=$('highV').value;
  fetch(`/setSettings?low=${lowV}&warn=${warnV}&high=${highV}`).then(r=>{if(r.ok){alert('设置已保存!')}else{alert('保存失败!')}}).catch(e=>alert('请求出错: '+e));
}
$('relayBtn').addEventListener('click',()=>{const newState=$('relayBtn').classList.contains('on');fetch('/setRelay?state='+(newState?'1':'0')).then(()=>setTimeout(fetchData,200))});
$('otaForm').addEventListener('submit', function(e){$('otaUi').style.display='none';$('otaStatus').innerHTML='<h4>正在上传并更新...</h4><p>请勿关闭此页面或断开设备电源。设备将在大约一分钟后自动重启。</p>';});

document.addEventListener('DOMContentLoaded', (event) => {
  fetchInitialState();
  fetchData();
  initChart();
});

setInterval(fetchData,2500);
setInterval(updateChart,60000);
</script></body></html>
)HTML";

const char OTA_SUCCESS_HTML[] PROGMEM = R"HTML(<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>更新成功</title><style>body{background-color:#111827;color:#d1d5db;font-family:system-ui;text-align:center;padding-top:50px;}div{background-color:#1f2937;padding:30px;border-radius:12px;display:inline-block;}h1{color:#22c55e;}</style></head><body><div><h1>更新成功!</h1><p>设备正在重启,请在约1分钟后重新连接。</p></div></body></html>)HTML";
const char OTA_FAIL_HTML[] PROGMEM = R"HTML(<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>更新失败</title><style>body{background-color:#111827;color:#d1d5db;font-family:system-ui;text-align:center;padding-top:50px;}div{background-color:#1f2937;padding:30px;border-radius:12px;display:inline-block;}h1{color:#ef4444;}</style></head><body><div><h1>更新失败!</h1><p>请检查上传的固件文件(.bin)是否正确,然后返回重试。</p></div></body></html>)HTML";


// ============== 通知发送函数 ==============
#if defined(ENABLE_EMAIL_NOTIFICATION)
void smtpCallback(SMTP_Status status){
  Serial.println(status.info());
  if (status.success()){
    Serial.println("----------------");
    MailClient.printf("Message sent success: %d\n", status.completedCount());
    MailClient.printf("Message sent failed: %d\n", status.failedCount());
    Serial.println("----------------\n");
  }
}

void sendEmailNotification(String subject, String message) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[!!] 无法发送邮件,WiFi未连接。");
    return;
  }
  
  Session_Config config;
  config.server.host_name = SMTP_HOST;
  config.server.port = SMTP_PORT;
  config.login.email = AUTHOR_EMAIL;
  config.login.password = AUTHOR_PASSWORD;
  config.login.user_domain = F("127.0.0.1");

  config.time.ntp_server = NTP_SERVERS;
  config.time.gmt_offset = GMT_OFFSET;
  config.time.day_light_offset = DAYLIGHT_OFFSET;

  SMTP_Message email;
  email.sender.name = F("ESP8266 继电器");
  email.sender.email = AUTHOR_EMAIL;
  email.subject = subject;
  email.addRecipient(F("User"), RECIPIENT_EMAIL);
  
  String htmlMsg = "<h2>" + subject + "</h2><p>" + message + "</p><p>设备名称: " + String(deviceName) + "</p><p>当前时间: " + timeClient.getFormattedTime() + "</p>";
  email.html.content = htmlMsg;
  email.html.transfer_encoding = Content_Transfer_Encoding::enc_base64;

  Serial.println("准备发送邮件...");
  if (!smtp.connect(&config)) {
    MailClient.printf("Connection error, Status Code: %d, Error Code: %d, Reason: %s\n", smtp.statusCode(), smtp.errorCode(), smtp.errorReason().c_str());
    return;
  }

  if (!MailClient.sendMail(&smtp, &email)) {
    MailClient.printf("Error, Status Code: %d, Error Code: %d, Reason: %s\n", smtp.statusCode(), smtp.errorCode(), smtp.errorReason().c_str());
  }
}
#elif defined(ENABLE_IFTTT_NOTIFICATION)
void sendIFTTTNotification(String value1, String value2) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[!!] 无法发送IFTTT通知,WiFi未连接。");
    return;
  }
  
  WiFiClientSecure client;
  client.setInsecure();
  
  if (!client.connect("maker.ifttt.com", 443)) {
    Serial.println("[!!] IFTTT 连接失败!");
    return;
  }

  String url = String("/trigger/") + IFTTT_EVENT_NAME + "/with/key/" + IFTTT_API_KEY +
               "?value1=" + value1 + "&value2=" + value2;
  
  client.print(String("GET ") + url + " HTTP/1.1\r\n" +
               "Host: maker.ifttt.com\r\n" +
               "Connection: close\r\n\r\n");
  
  client.stop();
  Serial.println("[OK] IFTTT请求已发送。");
}
#endif

// ============== EEPROM 函数 ==============
void loadSettings() {
  EEPROM.begin(EEPROM_SIZE);
  uint16_t magic; EEPROM.get(ADDR_MAGIC_NUM, magic);
  if (magic == EEPROM_MAGIC_NUMBER) {
    Serial.println("[OK] 从EEPROM加载数据...");
    EEPROM.get(ADDR_LOW_V, lowVoltageThreshold); 
    EEPROM.get(ADDR_HIGH_V, highVoltageThreshold);
    EEPROM.get(ADDR_WARN_V, warningVoltageThreshold);
  } else {
    Serial.println("[!!] EEPROM未初始化, 使用默认值并写入。");
    EEPROM.put(ADDR_MAGIC_NUM, EEPROM_MAGIC_NUMBER); 
    EEPROM.put(ADDR_LOW_V, lowVoltageThreshold);
    EEPROM.put(ADDR_HIGH_V, highVoltageThreshold);
    EEPROM.put(ADDR_WARN_V, warningVoltageThreshold);
    EEPROM.commit();
  }
  EEPROM.end();
  Serial.printf("  -> 低压:%.2fV, 警告:%.2fV, 高压:%.2fV\n", lowVoltageThreshold, warningVoltageThreshold, highVoltageThreshold);
}
void saveSettings() {
  EEPROM.begin(EEPROM_SIZE);
  EEPROM.put(ADDR_LOW_V, lowVoltageThreshold); 
  EEPROM.put(ADDR_HIGH_V, highVoltageThreshold);
  EEPROM.put(ADDR_WARN_V, warningVoltageThreshold);
  EEPROM.commit(); 
  EEPROM.end();
  Serial.println("[OK] 电压阈值已保存到EEPROM。");
}


// ============== 核心功能函数 ==============
void recordVoltageHistory() {
  float voltage = 0.0;
  if (ina219_ok) {
    voltage = ina219.getBusVoltage_V();
    if (voltage < 0.1) voltage = 0.0;
  }
  
  File vlogFile = LittleFS.open(VLOG_FILE_PATH, "r+");
  if (!vlogFile) {
    Serial.println("[错误] 无法打开电压日志文件进行写入!");
    return;
  }

  vlogFile.seek(sizeof(int) + (historyIndex * sizeof(float)), SeekSet);
  vlogFile.write((byte*)&voltage, sizeof(float));

  historyIndex = (historyIndex + 1) % DATA_POINTS;

  vlogFile.seek(0, SeekSet);
  vlogFile.write((byte*)&historyIndex, sizeof(int));
  
  vlogFile.close();
}

// v6.16 修正: 反转继电器逻辑
void setRelay(bool state) {
  relayState = state;
  // 低电平触发: true (ON) -> LOW, false (OFF) -> HIGH
  digitalWrite(RELAY_PIN, relayState ? LOW : HIGH);
  Serial.printf("继电器 (Pin %d) 已设置为: %s (输出电平: %s)\n", RELAY_PIN, relayState ? "ON" : "OFF", relayState ? "LOW" : "HIGH");
}

void checkVoltageProtection() {
  if (!ina219_ok) return;
  busVoltage = ina219.getBusVoltage_V();
  
  bool voltageIsDecreasing = (busVoltage < (lastBusVoltage - 0.02));

  if (relayState && busVoltage <= warningVoltageThreshold && voltageIsDecreasing) {
      isVoltageWarning = true;
      if (millis() - lastWarningNoticeTime > NOTIFICATION_COOLDOWN_MS || lastWarningNoticeTime == 0) {
          Serial.printf("!!! 电压警告 (%.2fV <= %.2fV 且呈下降趋势),发送通知。\n", busVoltage, warningVoltageThreshold);
          #if defined(ENABLE_EMAIL_NOTIFICATION)
            String subject = "[电压警告] " + String(deviceName);
            String message = "设备当前电压为 " + String(busVoltage, 2) + "V,已低于警告阈值 " + String(warningVoltageThreshold, 2) + "V。";
            sendEmailNotification(subject, message);
          #elif defined(ENABLE_IFTTT_NOTIFICATION)
            sendIFTTTNotification(String(deviceName) + "%20Voltage%20Warning", String(busVoltage, 2));
          #endif
          lastWarningNoticeTime = millis();
      }
  } 
  else if (busVoltage > (warningVoltageThreshold + WARNING_HYSTERESIS_V)) {
      if (isVoltageWarning) {
          Serial.printf("[OK] 电压已恢复到 %.2fV,高于警告重置阈值(%.2fV)。解除警告。\n", busVoltage, warningVoltageThreshold + WARNING_HYSTERESIS_V);
      }
      isVoltageWarning = false;
  }
  else if (busVoltage > warningVoltageThreshold) {
      isVoltageWarning = false;
  }

  if (isLockedOut) {
    if (millis() - lockoutStartTime >= LOCKOUT_DURATION_MS) {
      Serial.println("[OK] 1小时锁定时间已到,解除锁定。"); 
      isLockedOut = false;
    } else { 
      lastBusVoltage = busVoltage;
      return; 
    }
  }

  if (!relayState && !isLockedOut && busVoltage >= highVoltageThreshold) {
    Serial.printf("检测到高电压 (%.2fV >= %.2fV),自动开启继电器。\n", busVoltage, highVoltageThreshold);
    setRelay(true);
  }
  
  if (relayState && busVoltage > 0.1 && busVoltage < lowVoltageThreshold) {
    Serial.printf("!!! 触发低压保护 (%.2fV < %.2fV),自动关闭继电器并锁定1小时。\n", busVoltage, lowVoltageThreshold);
    if (millis() - lastLockoutNoticeTime > NOTIFICATION_COOLDOWN_MS || lastLockoutNoticeTime == 0) {
      #if defined(ENABLE_EMAIL_NOTIFICATION)
          String subject = "[严重] 低压保护已触发! " + String(deviceName);
          String message = "设备当前电压为 " + String(busVoltage, 2) + "V,已触发低压保护 (" + String(lowVoltageThreshold, 2) + "V)。继电器已关闭并锁定1小时。";
          sendEmailNotification(subject, message);
      #elif defined(ENABLE_IFTTT_NOTIFICATION)
          sendIFTTTNotification(String(deviceName) + "%20Low%20Voltage%20Shutdown", String(busVoltage, 2));
      #endif
      lastLockoutNoticeTime = millis();
    }
    isLockedOut = true; 
    lockoutStartTime = millis(); 
    setRelay(false);
  }

  if (busVoltage > 0.1 && busVoltage < REBOOT_VOLTAGE_THRESHOLD) {
    if (lowVoltageRebootTimer == 0) {
      lowVoltageRebootTimer = millis();
      Serial.printf("[警告] 电压低于 %.2fV, 启动1小时重启倒计时。\n", REBOOT_VOLTAGE_THRESHOLD);
    }
    else if (millis() - lowVoltageRebootTimer > REBOOT_TIMER_DURATION_MS) {
      Serial.println("[严重] 电压持续低于10V超过1小时,设备将重启!");
      delay(100);
      ESP.restart();
    }
  } else {
    if (lowVoltageRebootTimer != 0) {
      Serial.printf("[OK] 电压已恢复至 %.2fV以上, 取消重启倒计时。\n", REBOOT_VOLTAGE_THRESHOLD);
      lowVoltageRebootTimer = 0;
    }
  }
  
  lastBusVoltage = busVoltage;
}


// ============== Web路由处理函数 ==============
void handleRoot() {
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "text/html; charset=UTF-8", "");
  
  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
  server.sendHeader("Pragma", "no-cache");
  server.sendHeader("Expires", "-1");
  
  server.sendContent_P(MAIN_HTML_PART1);
  server.sendContent_P(MAIN_HTML_PART2);
  
  server.sendContent("");
}

void handleGetData() {
  if (ina219_ok) busVoltage = ina219.getBusVoltage_V();
  timeClient.update();
  long remaining_min = 0;
  if (isLockedOut) {
    unsigned long elapsed_ms = millis() - lockoutStartTime;
    if (elapsed_ms < LOCKOUT_DURATION_MS) remaining_min = (LOCKOUT_DURATION_MS - elapsed_ms) / 60000;
  }
  String json = "{";
  json += "\"voltage\":" + String(busVoltage, 2) + ",";
  json += "\"relay\":" + String(relayState ? "true" : "false") + ",";
  json += "\"time\":\"" + timeClient.getFormattedTime() + "\",";
  json += "\"lockout\":" + String(isLockedOut ? "true" : "false") + ",";
  json += "\"lockout_rem\":" + String(remaining_min) + ",";
  json += "\"voltage_warning\":" + String(isVoltageWarning ? "true" : "false");
  json += "}";
  server.send(200, "application/json", json);
}

void handleGetStatus() {
  String json = "{";
  json += "\"relay\":" + String(relayState ? "true" : "false") + ",";
  json += "\"low_v\":" + String(lowVoltageThreshold, 2) + ",";
  json += "\"warn_v\":" + String(warningVoltageThreshold, 2) + ",";
  json += "\"high_v\":" + String(highVoltageThreshold, 2) + ",";
  json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
  json += "\"chip_id\":\"" + String(ESP.getChipId(), HEX) + "\",";
  
  FSInfo fs_info;
  LittleFS.info(fs_info);
  json += "\"free_heap\":" + String(ESP.getFreeHeap() / 1024) + ",";
  json += "\"total_heap\":" + String(81920 / 1024) + ",";
  json += "\"fs_free\":" + String((fs_info.totalBytes - fs_info.usedBytes) / 1024) + ",";
  json += "\"fs_total\":" + String(fs_info.totalBytes / 1024) + ",";
  json += "\"cpu_usage\":" + String(cpuUsage, 1);
  json += "}";
  server.send(200, "application/json", json);
}
void handleSetRelay() {
  if (server.hasArg("state")) {
    bool newState = server.arg("state").toInt() == 1;
    if (newState && isLockedOut) {
      Serial.println("[OK] 手动操作覆盖了低压锁定。");
      isLockedOut = false;
    }
    setRelay(newState);
    server.send(200, "text/plain", "OK");
  } else { server.send(400, "text/plain", "Bad Request"); }
}
void handleSetSettings() {
  if (server.hasArg("low") && server.hasArg("high") && server.hasArg("warn")) {
    lowVoltageThreshold = server.arg("low").toFloat();
    highVoltageThreshold = server.arg("high").toFloat();
    warningVoltageThreshold = server.arg("warn").toFloat();
    saveSettings();
    server.send(200, "text/plain", "OK");
  } else { server.send(400, "text/plain", "Bad Request"); }
}

void handleGetChartData() {
  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "application/json", "");
  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");

  File vlogFile = LittleFS.open(VLOG_FILE_PATH, "r");
  if (!vlogFile) {
    server.sendContent("{\"error\":\"Failed to read log file\"}");
    server.sendContent("");
    return;
  }
  
  int savedIndex;
  vlogFile.read((byte*)&savedIndex, sizeof(int));
  
  server.sendContent("{\"labels\":[");
  for (int i = 0; i < DATA_POINTS; i++) {
    String label = "";
    if (i == DATA_POINTS - 1) {
      label = "\"Now\"";
    } else if ((DATA_POINTS - 1 - i) % 180 == 0) {
      label = "\"-" + String((DATA_POINTS - 1 - i) / 60) + "h\"";
    } else {
      label = "\"\"";
    }
    server.sendContent(label);
    if (i < DATA_POINTS - 1) {
      server.sendContent(",");
    }
  }
  server.sendContent("],\"data\":[");
  
  int startIndex = savedIndex;
  float voltage;
  
  for (int i = 0; i < DATA_POINTS; i++) {
    int currentIndex = (startIndex + i) % DATA_POINTS;
    vlogFile.seek(sizeof(int) + (currentIndex * sizeof(float)), SeekSet);
    vlogFile.read((byte*)&voltage, sizeof(float));

    if (isnan(voltage) || voltage < 0.1) {
      server.sendContent("null");
    } else {
      server.sendContent(String(voltage, 2));
    }
    
    if (i < DATA_POINTS - 1) {
      server.sendContent(",");
    }
  }
  
  vlogFile.close();
  server.sendContent("]}");
  server.sendContent("");
}


// ============== SETUP ==============
void setup() {
  Serial.begin(115200);
  delay(100);
  Serial.println("\n\n===== ESP8266 智能继电器 & INA219 v6.16 (Inverted Relay Logic) =====");

  // 初始化文件系统
  if (!LittleFS.begin()) {
    Serial.println("[错误] 文件系统挂载失败!");
  } else {
    Serial.println("[OK] 文件系统已挂载。");
    File vlogFile = LittleFS.open(VLOG_FILE_PATH, "r+");
    if (!vlogFile) {
      Serial.println("[警告] 未找到电压日志文件,正在创建...");
      vlogFile = LittleFS.open(VLOG_FILE_PATH, "w+");
      if (vlogFile) {
        int initialIndex = 0;
        vlogFile.write((byte*)&initialIndex, sizeof(int));
        float nan_val = NAN;
        for (int i = 0; i < DATA_POINTS; i++) {
          vlogFile.write((byte*)&nan_val, sizeof(float));
        }
        vlogFile.close();
        Serial.println("[OK] 新的电压日志文件已创建并初始化。");
      } else {
        Serial.println("[错误] 创建电压日志文件失败!");
      }
    } else {
      vlogFile.read((byte*)&historyIndex, sizeof(int));
      vlogFile.close();
      Serial.printf("[OK] 已从文件加载历史数据指针: %d\n", historyIndex);
    }
  }

  pinMode(RELAY_PIN, OUTPUT);
  // v6.16 修正: 默认关闭状态,对于低电平触发模块,是输出HIGH
  digitalWrite(RELAY_PIN, HIGH);
  relayState = false;
  Serial.println("[OK] 继电器已设置为默认关闭状态 (输出电平: HIGH)。");

  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  if (!ina219.begin()) {
    Serial.println("[!!] 硬件错误: 未能找到INA219芯片! 请检查I2C接线。");
    ina219_ok = false;
  } else {
    Serial.println("[OK] INA219 通信成功。");
    ina219_ok = true;
  }

  loadSettings();

  WiFi.mode(WIFI_STA);
  WiFi.hostname(deviceName);
  WiFi.begin(ssid, password);
  Serial.print("连接 Wi-Fi");
  int wifi_retries = 20;
  while (WiFi.status() != WL_CONNECTED && wifi_retries > 0) { delay(500); Serial.print("."); wifi_retries--; }
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("\n[!!] WiFi 连接失败! 设备将重启。");
    delay(1000); ESP.restart();
  }
  Serial.println("\n[OK] WiFi 已连接!");
  Serial.print("IPv4 地址: "); Serial.println(WiFi.localIP());
  
  timeClient.begin();
  timeClient.forceUpdate();
  Serial.println("[OK] Web UI的NTP 时间服务已同步。");

  #if defined(ENABLE_EMAIL_NOTIFICATION)
    smtp.debug(1);
    smtp.callback(smtpCallback);
  #endif
  
  if (MDNS.begin(deviceName)) {
    MDNS.addService("http", "tcp", WEB_SERVER_PORT);
    Serial.printf("[OK] mDNS 已启动, 访问: http://%s.local\n", deviceName);
  }
  
  server.on("/", HTTP_GET, handleRoot);
  server.on("/getData", HTTP_GET, handleGetData);
  server.on("/getStatus", HTTP_GET, handleGetStatus);
  server.on("/setRelay", HTTP_GET, handleSetRelay);
  server.on("/setSettings", HTTP_GET, handleSetSettings);
  server.on("/getChartData", HTTP_GET, handleGetChartData);
  server.on("/update", HTTP_POST, []() {
    server.sendHeader("Connection", "close");
    server.send(200, "text/html", Update.hasError() ? OTA_FAIL_HTML : OTA_SUCCESS_HTML);
    if (!Update.hasError()) { delay(1000); ESP.restart(); }
  }, []() {
    HTTPUpload& upload = server.upload();
    if (upload.status == UPLOAD_FILE_START) {
      Serial.printf("[OTA] Update Start: %s\n", upload.filename.c_str());
      uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
      if (!Update.begin(maxSketchSpace)) { Update.printError(Serial); }
    } else if (upload.status == UPLOAD_FILE_WRITE) {
      if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { Update.printError(Serial); }
    } else if (upload.status == UPLOAD_FILE_END) {
      if (Update.end(true)) { Serial.printf("[OTA] Update Success: %u bytes\n", upload.totalSize); } 
      else { Update.printError(Serial); }
    }
  });
  
  server.begin();
  Serial.printf("[OK] HTTP 服务器已启动。\n========================================\n");
}

// ============== LOOP ==============
void loop() {
  countCpuIdle();
  
  server.handleClient();
  MDNS.update();

  static unsigned long lastVoltageCheck = 0;
  if (millis() - lastVoltageCheck > 5000) {
    lastVoltageCheck = millis();
    checkVoltageProtection();
  }

  static unsigned long lastHistoryRecord = 0;
  if (millis() - lastHistoryRecord > DATA_INTERVAL_MS) {
    lastHistoryRecord = millis();
    recordVoltageHistory();
  }

  if (millis() < 5000) {
     if (millis() > 2000 && lastCpuMeasureTime == 0) {
        cpuIdleCounter = 0;
        lastCpuMeasureTime = millis();
     }
     if (millis() > 3000 && maxIdleCountsPerSecond == 0) {
        maxIdleCountsPerSecond = cpuIdleCounter;
        if (maxIdleCountsPerSecond < 1000) maxIdleCountsPerSecond = 2000000;
        Serial.printf("[校准] CPU最大空闲计数值: %lu\n", maxIdleCountsPerSecond);
        cpuIdleCounter = 0;
        lastCpuMeasureTime = millis();
     }
  } else if (maxIdleCountsPerSecond > 0 && (millis() - lastCpuMeasureTime > CPU_MEASURE_INTERVAL_MS)) {
    float idleRatio = (float)cpuIdleCounter / maxIdleCountsPerSecond;
    if (idleRatio > 1.0) idleRatio = 1.0;
    cpuUsage = (1.0 - idleRatio) * 100.0;
    
    cpuIdleCounter = 0;
    lastCpuMeasureTime = millis();
  }
}

[电压警告] esp8266-smart-relay

设备当前电压为 12.62V,已低于警告阈值 12.70V。

设备名称: esp8266-smart-relay

当前时间: 14:18:34

[严重] 低压保护已触发! esp8266-smart-relay

设备当前电压为 12.65V,已触发低压保护 (12.70V)。继电器已关闭并锁定1小时。

设备名称: esp8266-smart-relay

当前时间: 14:21:19

第三部分:关键问题解答与经验分享

在项目进行中,我们总结了几个核心问题的答案:

  1. 如何精确测量电路参数?

    • 通过上文所述的ESP8266+INA219并联方案,我们实现了对电池电压的实验室级别的高精度测量,这是整个智能控制系统的基石。
  2. 如何实现高效发电?

    • 首选MPPT控制器:它能实时追踪太阳能板的最大功率点,相比传统PWM控制器能提升15-30%的充电效率,尤其在光照变化的场景下。
    • 线材至关重要:一个真实的教训——“我用另外一块200w的连接控制器测量22v,原来是线问题,拼多多买很便宜6平方20米线有问题,换了线就行了”。劣质或过细的线材会产生巨大的压降和损耗,导致太阳能板的电压在到达控制器之前就已大幅衰减,严重影响发电效率。请务必使用横截面积足够、质量可靠的铜缆。
    • 安装角度与清洁:确保太阳能板朝向最佳角度,并定期清理表面的灰尘,也能显著提升发电量。
  3. 螺丝及连接的重要性?

    • 所有电气连接点(特别是电池、控制器和逆变器的大电流端子)都必须用合适的工具拧紧。任何松动的连接都会增加接触电阻,导致发热、能量损耗,甚至引发火灾风险。

第四部分:终极扩展 - 实现市电与太阳能的毫秒级无缝切换

方法一:拼多多买不断电双电源切换开关(73元)

最高80A大电流,用1个多月还行,偶尔切换市电时候电脑会断电

 

方法二:(待实现)

对于需要7x24小时不间断运行的设备,如台式电脑或服务器,普通的继电器切换方案是不可接受的。这是因为机械继电器的切换时间(约10-20ms)加上程序的检测延迟,总中断时间很容易超过20ms,这足以让台式机电源判定为断电而关机。

要实现无缝切换(断电时间<20ms),我们需要对硬件和软件进行全面升级。

硬件提速与续流补偿方案

  1. 用固态继电器 (SSR) 替代机械继电器

    • 核心优势:SSR是半导体开关,没有机械触点,其响应时间通常小于1ms。这从根本上消除了机械延迟。
    • 选型:选择两路DC-AC固态继电器(如G3MB-202P),一路控制太阳能逆变器输出,一路控制市电输出。
  2. 增加超级电容续流电路

    • 作用:在切换的瞬间(哪怕只有几个毫秒的中断),由超级电容组为负载的关键电路(如台式机电源的5V待机电路)提供能量,维持其不掉电。
    • 实现:在负载输入端并联一个5.5V/1F的超级电容模块(需经过220V降压和稳压)。

完整硬件方案 (成本约150元)

组件 型号/参数 作用
ESP8266开发板 NodeMCU (V3) 高速电压监测+控制SSR
固态继电器 (SSR) G3MB-202P (2路, 220V/2A) 无延迟切换电源 (实现互锁)
高精度电压检测模块 MAX17048 (或INA219) 替代分压电阻,检测精度达±1mV
超级电容模块 5.5V/1F (2串) 切换瞬间续流,防止断电
隔离变压器 500W (220V转220V) 确保市电与太阳能系统电气隔离,保障安全

电路设计与程序优化

  • 硬件互锁:设计电路,确保两个SSR的控制信号在物理上不能同时为高电平,即使软件出错,也能防止市电与逆变器输出短路。
  • 中断检测:修改ESP8266代码,使用中断引脚来监测电压变化。当电压跌破阈值时,能瞬间(微秒级)触发中断服务程序,而不是通过loop()轮询(毫秒级),极大缩短了反应时间。

核心代码片段 (中断切换逻辑)

#include <ESP8266WiFi.h>

const int ssrSolar = D1;  // 太阳能侧SSR
const int ssrGrid = D2;   // 市电侧SSR
const int voltageAlertPin = D5; // 连接到电压比较器的中断引脚
volatile bool needSwitchToGrid = false;

// 中断服务函数:检测到欠压立即标记切换
ICACHE_RAM_ATTR void triggerSwitch() {
  needSwitchToGrid = true;
}

void setup() {
  pinMode(ssrSolar, OUTPUT);
  pinMode(ssrGrid, OUTPUT);
  pinMode(voltageAlertPin, INPUT_PULLUP);
  
  // 初始状态:太阳能供电
  digitalWrite(ssrSolar, HIGH);
  digitalWrite(ssrGrid, LOW);
  
  // 绑定中断
  attachInterrupt(digitalPinToInterrupt(voltageAlertPin), triggerSwitch, FALLING);
}

void loop() {
  if (needSwitchToGrid) {
    // 无缝切换逻辑:先断后通,间隔极短
    digitalWrite(ssrSolar, LOW);   // 立即断开太阳能
    delayMicroseconds(500);        // 500微秒延迟,确保SSR1完全断开
    digitalWrite(ssrGrid, HIGH);   // 立即闭合市电
    needSwitchToGrid = false;      // 清除标志位
  }
  
  // (此处应有电压恢复后,切换回太阳能的逻辑)
}

 

第五部分:避免满充满放设置

您可以根据您对电池寿命和单次使用时长的侧重,选择不同的方案:

方案一:均衡型(推荐,兼顾电池寿命和使用)

这个方案旨在保护电池,延长其循环寿命,同时也能使用大部分电量。

  • 高压开启 (V): 13.0
    • 说明: 当电池充电电压回到13.0V时,继电器会重新接通用电器。这个电压大约对应40%-50%的剩余电量,确保电池已有一定的恢复,再接上负载,可以避免刚充一点电就又立刻掉到低压关闭的情况。
  • 电压警告 (V): 12.5
    • 说明: 当电池电压下降到12.5V时,发出警告。这个电压点之后,磷酸铁锂电池的电压会开始加速下降,给您一个比较充足的预警时间。
  • 低压关闭 (V): 12.0
    • 说明: 当电池电压降低到12.0V时,继电器会切断电源,停止放电。这大约对应10%的剩余电量,是一个非常安全的截止点,能有效避免电池过放,大大延长电池的整体寿命。

方案二:长续航型(类似您图中的设置,但有优化)

这个方案以榨取更多单次电量为目的,但会对电池寿命造成较大损耗,不建议长期使用。

  • 高压开启 (V): 13.1
    • 说明: 您图中的13.1V设置很好。这意味着电池需要充到比较高的电量(约60%)才会重新启动负载,这在深度放电后是很有必要的。
  • 电压警告 (V): 11.5
    • 说明: 图中的11V警告值距离10.5V的关闭电压太近了,几乎没有反应时间。建议设置为11.5V,可以提前预警。
  • 低压关闭 (V): 10.5
    • 说明: 这是磷酸铁锂电池放电的极限电压,对应电量基本为0%。频繁进行如此深度的放电会严重损害电池寿命。只有在您确实需要榨干所有电量,并且不介意电池加速衰减的情况下才使用此设置。

总结

参数 均衡型 (推荐) 长续航型 (慎用)
高压开启 (V) 13.0 13.1
电压警告 (V) 12.1 11.5
低压关闭 (V) 12.0 10.5

核心建议:

为了您的电池能用得更久,强烈建议您使用 “均衡型” 的设置。虽然单次可用时间稍短,但从长远来看,对电池的保护是最好的。

总结

通过上述步骤,我们从一个基础的离网太阳能系统出发,成功地构建了一个具备高精度监控能力的智能控制中心,并最终通过硬件升级和软件优化,实现了一个成本低廉、性能卓越的毫秒级无缝电源切换系统。这个过程不仅验证了方案的可行性,更展示了DIY精神在解决复杂工程问题中的巨大潜力。