/*******************************************************************************
*
* Obtain hotwater tank temperature, time-of-day and current power export.
* Send excess solar power to the hotwater tank. Boost if necessary to reach
* the daily target temperature.
*
*******************************************************************************/
#include <sys/time.h> // struct timeval
#include <coredecls.h> // settimeofday_cb()
#include <math.h>
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPClient.h>
#include <timeZone.h>
#include <signalSmoothing.h>
#include <ntoa.h>
#include <timeToString.h>
#include <stringBuilder.h>
#include <xmlBuilder.h>
#include <bugutil.h>
#include <wifiUtil.h>
#include <flash.h>
#include <bugutil.h>
#include <interpolate.h>
#include <keyvalue.h>
#include <svgTimeGraph.h>
#include <nmcu_DiverterGraph.h>
#include <warby_macros.h>
#include <math2.h>
///////////////////////////// Pin Assignment //////////////////////////////////////
//
// source: https://zoetrope.io/tech-blog/esp8266-bootloader-modes-and-gpio-state-startup
//
// GPIO_0 GPIO_2 GPIO_15
// UART Download Mode (Programming) 0 1 0
// Flash Startup (Normal) 1 1 0
// SD-Card Boot 0 0 1
//
// When choosing GPIO pins to use, its best to avoid GPIO 0, 2 and 15 unless
// absolutely necessary. If you do end up using them, you'll need
// pullups / pulldowns to ensure the correct bootloader mode.
// You should also be aware of the fact that GPIO 0 is driven as an output
// during startup (at least with NodeMCU).
//
// source: https://community.blynk.cc/t/esp8266-gpio-pins-info-restrictions-and-features/22872
// GPIO function ESP8266 notes / restrictions default behaviour on boot recc. for sensors for flash for run
// 0 I/O, CS for normal run, must be HIGH during boot / reset (has built in pull-up) oscillates, than stabilizes HIGH after ~100ms NO LOW HIGH
// 1 TXD reserved for frimware upload and serial monitor. not reccomended as GPIO pin LOW for ~50ms, then HIGH NO
// 2 I/O built-in led (active LOW). must be HIGH during boot / reset / wakeup (has built in pull-up) oscillates, than stabilizes HIGH after ~100ms NO HIGH HIGH
// 3 RXD reserved for firmware upload and serial monitor. not reccomended as GPIO pin LOW for ~50ms, then HIGH NO
// 4 I/O, SCL safe pin (first choice) LOW YES
// 5 I/O, SDA safe pin (first choice) LOW YES
// 6 - 11 these pins are reserved for mcu - flash communication, do not use! NO
// 12 I/O, MISO (SDO) quite safe pin HIGH for ~100ms, then LOW YES
// 13 I/O, MOSI (SDI) quite safe pin HIGH for ~100ms, then LOW YES
// 14 I/O, SCK quite safe pin HIGH for ~100ms, then LOW YES
// 15 I/O must be LOW during boot / reset / wakeup (has built in pull-down) LOW NO LOW LOW
// 16 I/O (no interrupt) for sleep mode, connect to EXT_RSTB, on wakeup will output LOW for reset HIGH for ~100ms, then falls to ~1V? NO
///////////////////////////// Pin Assignment //////////////////////////////////////
//
// Defining available LED's here, because the value of LED_BUILTIN has been known change with updates.
#define LED_1 16 // D0, WAKE
#define LED_2 2 // D4, TXD1
/////////////////////////// time ////////////////////////////////////////////
static bool cbtime_set = false; // Do we have a valid time-of-day?
static void time_is_set(void) {
cbtime_set = true;
}
static void setup_ntp(void)
{
settimeofday_cb(time_is_set);
configTime(0, 0, "pool.ntp.org");
}
/////////////////////////// Power declarations ///////////////////////////////
const int8_t analogInPin = A0; // I: Reads measured or thermoStat temp
const int8_t ssrControlPin = D1; // O: SSR control HIGH = "ON".
static os_timer_t ssr_control_timer;
// Generates a PWM control voltage for the SSR.
// Turn power on for nDivertCycles out of MaxCycles.
// PWM constants:
// Length of a cycle (msec)
#define CycleLength 20
// PWM period length in cycles.
#define PwmCycles 36
// Load power
#define Load WARBY_HWS_LOAD
//Average Power contributed by every "on" cycle
// within the PWM period. (=100W)
#define PPP (Load/PwmCycles)
// Target power range: try to get as close to "0" as possible
// dipping into import now and then should be more than compensated by
// minimising export.
#define MinExport 0
#define MaxExport PPP
// Target temperature to aim for by TargetTOD
#define TargetTemp 67
// Number of degrees the actual temp lags behind desired to invoke full power
#define TempTolerance 2
// Temp just above where the thermostat kicks in
// In between TargetTemp and MaxTemp we limit power to prevent the mechanical
// thermostat from kicking in before the heat has been equalised within
// the tank.
#define MaxTemp 69
// Time-of-day after which only solar power should be used (secs)
// (start of peak tariff)
#define TargetTOD (15 * SECS_PER_HOUR)
// Estimated temperature curve under full power.
// temp_curve[0] = final temp.
// temp_curve[1] = temp with 1 hr to go, etc.
#define temp_curve_count 9
static const uint8_t temp_curve[temp_curve_count] =
{ TargetTemp, TargetTemp, 62, 57, 50, 40, 28, 14, 0 };
// Cycles per period to divert excess power.
static int16_t nDivertCycles;
// Max cycles per period to prevent the thermostat from triggering early.
static int16_t nLimitCycles;
// Power required to reach <minTemp> by <TargetTOD>.
static int16_t nBoostCycles;
// The amount of power overload in terms of duty cycles.
static int16_t nOverloadCycles;
// The final calculated number of cycles.
static int16_t nCycles;
static uint32_t nextTargetUTC; // Next target time
static LowPass<1> waterTemp(5); // last recorded water temp.
// rolling average over 5s to compensate
// for ADC jitter.
static int16_t currentImport; // last recorded power import
static bool haveImport = false; // <currentImport> is valid.
static ESP8266WebServer server(80); // Create a webserver object that listens for HTTP request on port 80
void handleRoot(); // function prototypes for HTTP handlers
void handlePage(); // Request for one of the graphs
void handleNotFound();
/////////////////////////// connections //////////////////////////////////////
// (local) service providing current "whole house" power import/export
//#define HTTP_TARGET "60scotch.hopto.org:9200"
#define HTTP_TARGET "10.0.0.122:80"
//static WifiManager wm( "CnM", "isnochys", 15 );
static WifiManager wm( WARBY_WIFI_SSID, WARBY_WIFI_PW, 300 );
static float minDesiredTemp( const uint32_t secsToHeat )
// Minimum desired water temperature depening on how many seconds heating
// time left.
{
const uint32_t wholeHoursToHeat = secsToHeat / 3600;
if( wholeHoursToHeat >= (temp_curve_count -1) )
// We're still before the start of the curve, return the lowest value
{
return temp_curve[temp_curve_count -1];
}
const uint32_t wholeHoursToHeatSecs = wholeHoursToHeat * 3600;
const LinearIP ip(0.0, temp_curve[wholeHoursToHeat], 3600.0, temp_curve[wholeHoursToHeat +1] );
return ip.y( secsToHeat - wholeHoursToHeatSecs);
}
static void updateOverloadCycles( const int16_t curPower )
// Adjusts the reduction in power-cycles needed to limit overload.
// Increases immediately in case of overload, decrements every cycle if there is no overload.
{
if( curPower > WARBY_POWER_LIMIT )
{
nOverloadCycles += 1 + (( curPower - WARBY_POWER_LIMIT ) / PPP);
}
else
if( nOverloadCycles > 0 )
{
nOverloadCycles--;
}
}
static void updateBoost(const uint32_t utc, const float curTemp, const int16_t curPower)
// Check if it is still possible to reach <MinTemp> by <TargetTOD> at full power.
// If not, start increasing power from 0 to full over 15 mins.
// Effectively we'll reach <MinTemp> between <TargetTOD> and <TargetTOD+15m>
{
const uint32_t localTime = ae.toLocal( utc );
const uint32_t nextTargetLocal = nextTimeOfDay( localTime, TargetTOD );
// Time until which we may import power.
const uint32_t timeRemaining = (nextTargetLocal - localTime);
// Now calculate the temperature we should be at, given the remaining heating time.
const float targetTemp = minDesiredTemp( timeRemaining );
const LinearIP ip(targetTemp -TempTolerance, PwmCycles, targetTemp, 0.0 );
nBoostCycles = ip.yc( curTemp );
Serial.printf
( "waterTemp=%.2f, targetTemp=%.0f, nBoostCycles=%.2d\n",
curTemp,
targetTemp,
nBoostCycles
);
}
static void updateCycles( void )
// Combines all the calculated cycles into a single number.
{
nCycles = nBoostCycles; // Always boost if needed.
if( haveImport && ( nDivertCycles > nCycles ) )
// Increase power if we have excess solar power
{
nCycles = nDivertCycles;
}
if( nCycles > nLimitCycles )
// Limit power to prevent the mechanical thermostat from kicking in early.
{
nCycles = nLimitCycles;
}
// Reduce power if there is any overload.
nCycles -= nOverloadCycles;
}
static void ssr_control_fun( void * )
// Runs on a 20msec timer, i.e. once every cycle.
// counts cycles, turns off power after the specified # of cycles,
// resets at the end of the PWM period.
{
static int16_t iCurrentCycle; // Current cycle counter.
// Turn on SSR until we have reached nDivertCycles, or when we need to boost.
digitalWrite( ssrControlPin, iCurrentCycle < nCycles );
// Give each cycle a number 0..PwmCycles-1
iCurrentCycle = (iCurrentCycle+1) % PwmCycles;
}
void setup()
{
pinMode(LED_1, OUTPUT);
pinMode(ssrControlPin, OUTPUT);
Serial.begin(115200);
Serial.println();
Serial.println();
Serial.println();
Serial.println();
for (uint8_t t = 4; t > 0; t--) {
Serial.printf("[SERIAL] WAIT %d...\n", t);
Serial.flush();
delay(1000);
}
Serial.print(F("compile date: "));
Serial.print(F(__DATE__));
Serial.print(F(" "));
Serial.println(F(__TIME__));
setup_ntp();
WiFi.mode(WIFI_STA); // wifiClient
server.on(F("/"), handleRoot); // Call the 'handleRoot' function when a client requests URI "/"
server.on(F("/page"), handlePage); // Return the specified page
server.onNotFound(handleNotFound); // When a client requests an unknown URI (i.e. something other than "/"), call function "handleNotFound"
server.begin(); // Actually start the server
Serial.println(F("HTTP server started"));
os_timer_setfn(&ssr_control_timer, ssr_control_fun, NULL );
os_timer_arm( &ssr_control_timer, 20, true );// 20sec, or 1 phase.
}
float units_to_degrees( const int16_t units )
// Converts ADC units into degrees C.
// linear approximation from MCP9701 (thermometer) specs
{
const float V0C = 0.4; // 400 mV at 0 degree C.
const float TC = 0.0195; // 19.5 mV / degree C.
const float R1 = 119000; // Voltage divider to ADC R2/(R1+R2)
const float R2 = 101000;
const float F = R2/(R1+R2);
const float UpV = 1024; // ADC units/Volt
const int16_t zeroC = V0C * F * UpV; // ADC units at 0C
const float dpu = 1.0/(TC * F * UpV); // degrees / unit
const float temp = dpu*(units - zeroC);
// Serial.printf("ADC=%d, temp=%.2f\n", units, temp);
return temp;
}
static void updateDivertCycles( const int16_t exp )
// Updates nDivertCycles to match spare power.
{
if( exp < MinExport )
// Not exporting enough: reduce power.
// To prevent a feed back loop, reduce power slowly for smaller differences,
// but still fast for larger power mismatches.
{
nDivertCycles -= M2::max( 1, ((MinExport - exp)/PPP) -2);
nDivertCycles = M2::max( nDivertCycles, (int16_t)0 );
}
else
if( exp > MaxExport )
// To prevent a feed back loop, increase power slowly for smaller differences,
// but still fast for larger power mismatches.
{
nDivertCycles += M2::max( 1, ((exp - MaxExport)/PPP) -2);
nDivertCycles = M2::min( nDivertCycles, (int16_t)PwmCycles );
}
}
static void updateLimitCycles( const float curTemp )
// Limit the duty-cycle when water temperature exceeds TargetTemp
{
static LinearIP tempToCycles( TargetTemp, PwmCycles, MaxTemp, PwmCycles/8 );
nLimitCycles = (int16_t) tempToCycles.yc(curTemp);
}
// For testing.
//#define POWER_ADJUST( x ) ((x) + (PPP*nTargetOnCycles))
#define POWER_ADJUST( x ) (x)
//#define RequestURL F("http://60scotch.hopto.org:9200/read")
#define RequestURL F("http://"HTTP_TARGET"/read")
static bool updateCurrentImport(void)
// Gets the current power-import from "fourquadrant_power"
// Any kind of failure causes this function
// to report no power-data is available.
// This causes the load to be turned off
{
WiFiClient client;
HTTPClient http;
if (!wm.connect())
{
return false;
}
// We are connected to WiFi, get a power update.
Serial.print("[HTTP] begin...\n");
if (http.begin(client, RequestURL))
{
// start connection and send HTTP header
int httpCode = http.GET();
// httpCode will be negative on error
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
{
char s[128];
strcpy(s, http.getString().c_str());
const char *value;
if( getKValue( s, "avpower", &value ) )
// Got a value, adjust
{
currentImport = POWER_ADJUST(atoi(value));
return true;
}
else
{
Serial.println(F("Key not found"));
}
}
else
{
Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
}
else
{
Serial.printf("[HTTP] Unable to connect\n");
}
return false;
}
///////////////////////////// WebServer declarations ///////////////////////////////////
///////////////////////////// Web Display //////////////////////////////////////
#define STRINGBUF_SIZE (2*8192)
class PageBuilder : private XMLBuilder
// Constructs page via the constructor,
// inheritance reduces the amount of typing needed.
{
private:
const TimeSeries *pActiveChart;
void button(FSH text, const int8_t iPage, FSH style )
{
if( iPage < 0 )
// no button
{
return;
}
char url[16];
StringBuilder button_url_builder( url, 16 );
button_url_builder += F("/page?i=");
button_url_builder += iPage;
tag(F("p"));
tagStart(F("a"));
keyValue(F("href"), url);
tagEnd();
tagStart(F("button"));
keyValue(F("class"), style);
tagEnd();
value(text);
nodeClose();
nodeClose();
nodeClose();
}
public:
void build
( const TimeGraph *const pActiveChart, // Graph to build a page for
int8_t iPageLeft, // Page the left button points to, or -1
int8_t iPageRight // Page the right button points to, or -1
)
{
dbprint(F("entering PageBuilder::build: chart="));
dbprintln(pActiveChart->config.title);
writer.clear();
tagStart(F("html"));
field(F("lang=en-AU"));
tagEnd();
tag(F("head"));
tagStart(F("meta"));
keyValue(F("http-equiv"), F("refresh"));
keyValue(F("content"), pActiveChart->config.refreshInterval);
nodeClose();
node(F("title"), F("Solar Diverter"));
writer += F("<!DOCTYPE html>");
tag(F("head"));
tagStart(F("meta"));
keyValue(F("name"), F("viewport"));
keyValue(F("content"), F("width=device-width, initial-scale=1"));
nodeClose();
tagStart(F("link"));
keyValue(F("rel"), F("icon"));
keyValue(F("href"), F("data:,"));
nodeClose();
// CSS to style the left/right buttons
// Feel free to change the background-color and font-size attributes to fit your preferences
tag(F("style"));
write(F("html { font-family: Helvetica; display: inline-block; margin: 0px auto; text-align: center;}"));
write(F(".button { position:fixed; z-index:2; background-color: white; opacity:0.5; border: 2px solid; border-radius: 12px; color: black; padding: 4px 8px;"));
write(F("text-decoration: none; font-size: 30px; margin: 4px 2px; cursor: pointer; top:13%} "));
write(F(".left {left:5%} "));
write(F(".right {left:90%} "));
nodeClose();
nodeClose();
tag(F("style"));
write(F("body { background-color: #fffff; font-family: Arial, Helvetica, Sans-Serif; Color: #000088; font-size: 16px } "));
write(F("svg { position:fixed; z-index:1; top:0; left:0; height:100%; width:100% }"));
nodeClose();
nodeClose();
tag(F("body"));
tag(F("hl"));
writer += pActiveChart->config.title;
{
pActiveChart->resetNextLine();
while( pActiveChart->getNextLine( &writer ) );
}
nodeClose();
SvgTimeGraph c(writer);
c.build(*pActiveChart);
// Buttons
button(F("<<"), iPageLeft, F("button left") );
button(F(">>"), iPageRight, F("button right") );
nodeClose();
nodeClose();
Serial.println(F("leaving PageBuilder::build"));
};
PageBuilder( StringBuilder &writer ) : XMLBuilder(writer) {};
};
/////////////////////// Request Handler implementations ///////////////////////////
static const char p1h[] PROGMEM = "Solar Diverter (1 hr)";
static const char p3h[] PROGMEM = "Solar Diverter (3 hrs)";
static const char p12h[] PROGMEM = "Solar Diverter (12 hrs)";
static const char p48h[] PROGMEM = "Solar Diverter (48 hrs)";
static const char p1w[] PROGMEM = "Solar Diverter (7 days)";
class HHMMSS_Label : public TimeGraphXtickLabel
// Writes the parameter as UTC HH:MM:SS
{
void label( const int32_t v, StringBuilder *const pBuilder ) const
{
pBuilder->hhmmss(v);
}
};
static HHMMSS_Label hhmmssLabel;
class HHMM_Label : public TimeGraphXtickLabel
// Writes the parameter as UTC HH:MM
{
void label( const int32_t v, StringBuilder *const pBuilder ) const
{
pBuilder->hhmm(v);
}
};
static HHMM_Label hhmmLabel;
class HHMM_DDMMM_Label : public TimeGraphXtickLabel
// Writes the parameter as UTC HH:MM - MMM DD
{
void label( const int32_t v, StringBuilder *const pBuilder ) const
{
pBuilder->hhmm(v);
pBuilder->write(F(" - "));
pBuilder->ddmmm(v);
}
};
static HHMM_DDMMM_Label hhmm_ddmmmLabel;
static TimeGraphConfig cfg1h = {FPSTR(p1h), 180, 3600, 30, 6, 2, &hhmmLabel };
static TimeGraphConfig cfg3h = {FPSTR(p3h), 180, 3*3600, 60, 6, 6, &hhmmLabel };
static TimeGraphConfig cfg12h = {FPSTR(p12h), 144, 12*3600, 150, 6, 4, &hhmmLabel };
static TimeGraphConfig cfg48h = {FPSTR(p48h), 144, 48*3600, 300, 4, 4, &hhmmLabel };
static TimeGraphConfig cfg1w = {FPSTR(p1w), 168, 7*24*3600, 600, 7, 4, &hhmm_ddmmmLabel };
static DiverterGraph power1h = DiverterGraph( cfg1h );
static DiverterGraph power3h = DiverterGraph( cfg3h );
static DiverterGraph power12h = DiverterGraph( cfg12h );
static DiverterGraph power48h = DiverterGraph( cfg48h );
static DiverterGraph power1w = DiverterGraph( cfg1w );
static DiverterGraph *apd[] = {&power1h, &power3h, &power12h, &power48h, &power1w, NULL};
void sendPage( const int8_t iPage )
{
digitalWrite(LED_1, LOW);
Serial.print(F("SendPage "));
Serial.println(iPage);
char *htmlBuffer = new char[STRINGBUF_SIZE]; // output buffer where we construct the message to send.
StringBuilder htmlBuilder(htmlBuffer, STRINGBUF_SIZE); // basic string construction methods
PageBuilder c( htmlBuilder );
c.build(apd[iPage], apd[iPage+1]!=NULL ? iPage+1 : -1, iPage -1 );
server.send(200, F("text/html"), htmlBuffer);
delete [] htmlBuffer;
digitalWrite(LED_1, HIGH);
}
void handleRoot() { // When URI / is requested, send a web page with on/off buttoms showing current state
Serial.println(F("handleRoot"));
sendPage(2);
}
void handlePage() { // If a POST request is made to URI /WAVE
Serial.print(F("handlePage "));
Serial.println(server.uri());
sendPage(server.arg(0).toInt());
}
void handleNotFound() {
Serial.print(server.uri());
Serial.println(F(" Not found"));
server.send(404, F("text/plain"), "404: Not found"); // Send HTTP status 404 (Not Found) when there's no handler for the URI in the request
}
///////////////////////////// Main Loop //////////////////////////////////////
static float heatingPower(void)
// Calculates heating power based on global variables.
{
return PPP * nCycles;
}
void loop(void) {
server.handleClient(); // Listen for HTTP requests from clients
const uint32_t utc = utcTime();
static uint32_t lastTempSampleTime = 0;
if( utc > lastTempSampleTime )
{
waterTemp.nextSample(units_to_degrees(analogRead(analogInPin)), 1);
lastTempSampleTime = utc;
}
static uint32_t nextLoopTime = 0;
if( utc < nextLoopTime )
// wait until the appointed time for the next
// processing loop.
{
return;
}
digitalWrite(LED_1, LOW);
updateLimitCycles( waterTemp.curValue() );
haveImport = updateCurrentImport();
if (haveImport)
// We have a valid import value: adjust diversion power if needed.
{
updateDivertCycles( -currentImport );
updateOverloadCycles( currentImport );
// Next loop in 10 sec.
nextLoopTime = utc + 10;
}
else
// http call failed. Try again in 2 sec.
{
nextLoopTime = utc + 2;
}
if( cbtime_set )
// Check if we need to go to max power in order
// to get the water to TargetTemp by TargetTOD.
{
updateBoost(utc, waterTemp.curValue(), currentImport);
}
else
// We don't know the time. Half power irrespective of time
// until TargetTemp
{
nBoostCycles = (waterTemp.curValue() < TargetTemp) ? (PwmCycles / 2) : 0;
Serial.println("[NTP] WAIT");
}
updateCycles();
for( int i = 0; apd[i] != NULL; i++ )
// feed the graphs.
{
apd[i]->addSample(waterTemp.curValue(), currentImport, heatingPower(), utc );
}
Serial.printf
( "haveImport=%d, import=%d, temp=%.2f, divertCycles=%d, limitCycles=%d, boostCycles=%d\n",
haveImport,
currentImport,
waterTemp.curValue(),
nDivertCycles,
nLimitCycles,
nBoostCycles
);
digitalWrite(LED_1, HIGH);
}
Comments