Sitelet https://github.com/akomakom/esp32-spa-controller/commit/3834bb42b5ffd8b34d6b2d9983715c50f4a50c99
Skip to content

Commit 3834bb4

Browse files
committed
Scheduler rework
1 parent 5606944 commit 3834bb4

5 files changed

Lines changed: 492 additions & 162 deletions

File tree

‎controller/HotTubUtils.cpp‎

Lines changed: 136 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -60,49 +60,95 @@ SpaControl *SpaControlDependencies::getDependentControl() {
6060
void
6161
SpaControlScheduler::normalSchedule(u_int8_t percentageOfDayOnTime, u_int8_t numberOfTimesToRun, u_int8_t normalValueOn,
6262
u_int8_t normalValueOff) {
63+
// Convenience: express the old whole-day "% + segments" schedule as a single
64+
// time-of-day period (00:00 -> midnight). Used for code defaults and any caller
65+
// that still thinks in the old terms.
6366
checkBounds(normalValueOn);
6467
checkBounds(normalValueOff);
65-
normalSettings.percentageOfDayOnTime = std::max(0, std::min(100, (int)percentageOfDayOnTime));
66-
normalSettings.numberOfTimesToRun = std::max(1, (int)numberOfTimesToRun);
67-
normalSettings.normalValueOn = normalValueOn;
68-
normalSettings.normalValueOff = normalValueOff;
68+
int pct = std::max(0, std::min(100, (int)percentageOfDayOnTime));
69+
int segments = std::max(1, (int)numberOfTimesToRun);
70+
71+
SpaSchedulePeriod p;
72+
p.startHour = 0;
73+
p.cycleMinutes = (u_int16_t)std::max(1, 1440 / segments);
74+
p.onMinutes = (u_int16_t)((long)p.cycleMinutes * pct / 100);
75+
p.onValue = normalValueOn;
76+
p.offValue = normalValueOff;
77+
setSchedule(&p, 1);
78+
}
79+
80+
void SpaControlScheduler::setSchedule(const SpaSchedulePeriod* newPeriods, u_int8_t count) {
81+
if (count < 1) count = 1;
82+
if (count > MAX_SCHEDULE_PERIODS) count = MAX_SCHEDULE_PERIODS;
83+
84+
SpaSchedulePeriod tmp[MAX_SCHEDULE_PERIODS];
85+
for (u_int8_t i = 0; i < count; i++) {
86+
tmp[i] = newPeriods[i];
87+
if (tmp[i].startHour > 23) tmp[i].startHour = 23;
88+
if (tmp[i].cycleMinutes < 1) tmp[i].cycleMinutes = 1;
89+
if (tmp[i].cycleMinutes > 1440) tmp[i].cycleMinutes = 1440;
90+
if (tmp[i].onMinutes > 1440) tmp[i].onMinutes = 1440;
91+
tmp[i].onValue = std::min(max, std::max(min, tmp[i].onValue));
92+
tmp[i].offValue = std::min(max, std::max(min, tmp[i].offValue));
93+
}
94+
// sort by startHour ascending (tiny insertion sort)
95+
for (u_int8_t i = 1; i < count; i++) {
96+
SpaSchedulePeriod v = tmp[i];
97+
int j = i;
98+
while (j > 0 && tmp[j - 1].startHour > v.startHour) { tmp[j] = tmp[j - 1]; j--; }
99+
tmp[j] = v;
100+
}
101+
tmp[0].startHour = 0; // the first period always covers from midnight
69102

70-
// precalculate normal schedule variables
71-
float onLengthPercentage = (float)normalSettings.percentageOfDayOnTime / (float)normalSettings.numberOfTimesToRun;
72-
float offLengthPercentage = (float)(100 - normalSettings.percentageOfDayOnTime) / (float)normalSettings.numberOfTimesToRun;
73-
onOffLengthPercentage = onLengthPercentage + offLengthPercentage;
74-
// how ON compares with OFF (how far is the divider), as a 0-1 fraction, <0.5 is on, >0.5 is off
75-
onVsOff = onLengthPercentage / onOffLengthPercentage;
103+
scheduleSettings.version = SCHEDULE_SETTINGS_VERSION;
104+
scheduleSettings.periodCount = count;
105+
for (u_int8_t i = 0; i < count; i++) scheduleSettings.periods[i] = tmp[i];
106+
}
76107

108+
u_int8_t SpaControlScheduler::getCurrentPeriodIndex() {
109+
long secsToday = main_device_time->tm_hour * 3600L + main_device_time->tm_min * 60L + main_device_time->tm_sec;
110+
u_int8_t idx = 0;
111+
for (u_int8_t i = 0; i < scheduleSettings.periodCount; i++) {
112+
if ((long)scheduleSettings.periods[i].startHour * 3600L <= secsToday) {
113+
idx = i;
114+
} else {
115+
break; // periods are sorted by startHour; nothing later can match
116+
}
117+
}
118+
return idx;
77119
}
78120

79121
void SpaControlScheduler::persist(const char* eepromKey) {
80-
// persist this to EEPROM
81-
if (!app_preferences.putBytes(eepromKey, &normalSettings, sizeof(normalSettings))) {
82-
Serial.print("Unable to persist setting to key: ");
122+
scheduleSettings.version = SCHEDULE_SETTINGS_VERSION;
123+
if (!app_preferences.putBytes(eepromKey, &scheduleSettings, sizeof(scheduleSettings))) {
124+
Serial.print("Unable to persist schedule to key: ");
83125
Serial.println(eepromKey);
84126
} else {
85-
Serial.print("Persisted setting to key: ");
127+
Serial.print("Persisted schedule to key: ");
86128
Serial.println(eepromKey);
87129
}
88130
}
89131
void SpaControlScheduler::load(const char* eepromKey) {
90-
91-
Serial.print("Retrieving values from eeprom for key");
92-
Serial.println(eepromKey);
93-
94-
if (app_preferences.getBytes(eepromKey, &normalSettings, sizeof(normalSettings))) {
95-
// apply so that math can be precalculated
96-
Serial.print("Retrieved values from eeprom, eg: ");
97-
Serial.println(normalSettings.percentageOfDayOnTime);
98-
// Clamp persisted values into the current [min,max] range before applying.
99-
// Ranges can change between firmware versions (e.g. the F->C switch dropped the
100-
// heater ceiling from 104 to 40); without this, normalSchedule()'s checkBounds()
101-
// would throw on boot and abort.
102-
normalSettings.normalValueOn = std::min(max, std::max(min, normalSettings.normalValueOn));
103-
normalSettings.normalValueOff = std::min(max, std::max(min, normalSettings.normalValueOff));
104-
// apply the settings to precalculate other non-persisted variables
105-
normalSchedule(normalSettings.percentageOfDayOnTime, normalSettings.numberOfTimesToRun, normalSettings.normalValueOn, normalSettings.normalValueOff);
132+
// Read into a local and only adopt it if it is a full, current-version record.
133+
// Anything else (absent, or an older/short layout from a previous firmware) leaves
134+
// the code defaults in place -- this version intentionally does not migrate old
135+
// saved schedules; the user reconfigures via the new period editor.
136+
SpaScheduleSettings loaded;
137+
size_t got = app_preferences.getBytes(eepromKey, &loaded, sizeof(loaded));
138+
if (got == sizeof(loaded) && loaded.version == SCHEDULE_SETTINGS_VERSION
139+
&& loaded.periodCount >= 1 && loaded.periodCount <= MAX_SCHEDULE_PERIODS) {
140+
scheduleSettings = loaded;
141+
// Clamp persisted values into the current [min,max] range (ranges can change
142+
// between firmware versions, e.g. the heater ceiling) so nothing trips later.
143+
for (u_int8_t i = 0; i < scheduleSettings.periodCount; i++) {
144+
scheduleSettings.periods[i].onValue = std::min(max, std::max(min, scheduleSettings.periods[i].onValue));
145+
scheduleSettings.periods[i].offValue = std::min(max, std::max(min, scheduleSettings.periods[i].offValue));
146+
}
147+
Serial.print("Loaded schedule from key: ");
148+
Serial.println(eepromKey);
149+
} else {
150+
Serial.print("No current-version schedule for key (keeping defaults): ");
151+
Serial.println(eepromKey);
106152
}
107153
}
108154

@@ -128,28 +174,21 @@ void SpaControlScheduler::checkBounds(u_int8_t value) {
128174
}
129175

130176
u_int8_t SpaControlScheduler::getScheduledValue() {
177+
// Find the current time-of-day period, then apply its cycle: ON for the first
178+
// onMinutes of each cycleMinutes window (measured from the period's start).
179+
SpaSchedulePeriod& p = scheduleSettings.periods[getCurrentPeriodIndex()];
131180

132-
// trust normal schedule.
133-
// Normal schedule if a series of on-off time segments configured as a percentage of a day's length
134-
135-
// Percentage of each on and off segment:
136-
// TODO: precalculate on normalSchedule() call
137-
// length of each unit as a percentage of day length
138-
139-
// How far are we into the day (since midnight), in percentages?
140-
long elapsedSecsToday = main_device_time->tm_hour * 3600 + main_device_time->tm_min * 60 + main_device_time->tm_sec;
141-
float currentPercentageOfDay = (float)100 * elapsedSecsToday / 86400; // seconds per day
142-
// Serial.print("Current percentage of day: ");
143-
// Serial.println(currentPercentageOfDay);
144-
// which segment are we in currently?
145-
// how many on+off time units into the day are we?
146-
// eg we are 2.36 on/off segments into the day
147-
float onOffUnitCount = currentPercentageOfDay / onOffLengthPercentage;
148-
float fractionOfOnOffUnit = onOffUnitCount-(long)onOffUnitCount; //leave fraction only, eg 0.36
149-
150-
// Are we past the on->off divider in this on-then-off time unit?
151-
// note that the = in >= prevents a midnight blip when everything turns on for a second (0=0)
152-
return (fractionOfOnOffUnit >= onVsOff) ? normalSettings.normalValueOff : normalSettings.normalValueOn;
181+
if (p.cycleMinutes == 0 || p.onMinutes >= p.cycleMinutes) {
182+
return p.onValue; // always on for this period
183+
}
184+
if (p.onMinutes == 0) {
185+
return p.offValue; // always off for this period
186+
}
187+
long secsToday = main_device_time->tm_hour * 3600L + main_device_time->tm_min * 60L + main_device_time->tm_sec;
188+
long minsIntoPeriod = (secsToday - (long)p.startHour * 3600L) / 60L;
189+
if (minsIntoPeriod < 0) minsIntoPeriod = 0;
190+
long pos = minsIntoPeriod % p.cycleMinutes;
191+
return (pos < p.onMinutes) ? p.onValue : p.offValue;
153192
}
154193

155194
bool SpaControlScheduler::isOverrideScheduleEnabled() {
@@ -176,11 +215,21 @@ time_t SpaControlScheduler::getOverrideScheduleElapsedTime() {
176215

177216

178217
void SpaControlScheduler::updateConfigJsonString() {
179-
jsonConfig["percentageOfDayOnTime"] = normalSettings.percentageOfDayOnTime;
180-
jsonConfig["numberOfTimesToRun"] = normalSettings.numberOfTimesToRun;
181-
jsonConfig["normalValueOn"] = normalSettings.normalValueOn;
182-
jsonConfig["normalValueOff"] = normalSettings.normalValueOff;
183-
jsonConfig["overrideDefaultDurationSeconds"] = normalSettings.overrideDefaultDurationSeconds;
218+
jsonConfig.clear();
219+
jsonConfig["overrideDefaultDurationSeconds"] = scheduleSettings.overrideDefaultDurationSeconds;
220+
jsonConfig["min"] = min;
221+
jsonConfig["max"] = max;
222+
jsonConfig["maxPeriods"] = MAX_SCHEDULE_PERIODS;
223+
jsonConfig["gated"] = dependencyGatedBySchedule; // ozone: schedule arms, not runs
224+
JsonArray periods = jsonConfig.createNestedArray("periods");
225+
for (u_int8_t i = 0; i < scheduleSettings.periodCount; i++) {
226+
JsonObject o = periods.createNestedObject();
227+
o["startHour"] = scheduleSettings.periods[i].startHour;
228+
o["onMinutes"] = scheduleSettings.periods[i].onMinutes;
229+
o["cycleMinutes"] = scheduleSettings.periods[i].cycleMinutes;
230+
o["onValue"] = scheduleSettings.periods[i].onValue;
231+
o["offValue"] = scheduleSettings.periods[i].offValue;
232+
}
184233
serializeJson(jsonConfig, configString);
185234
}
186235

@@ -196,7 +245,7 @@ void SpaControl::toggle() {
196245
scheduleOverride(
197246
now(),
198247
now() + ((getOverrideScheduleRemainingTime() > 0) ?
199-
getOverrideScheduleRemainingTime() : normalSettings.overrideDefaultDurationSeconds),
248+
getOverrideScheduleRemainingTime() : scheduleSettings.overrideDefaultDurationSeconds),
200249
getNextValue());
201250
// Serial.println("PARENT!!! Value after toggle is ");
202251
// Serial.println(getEffectiveValue());
@@ -214,6 +263,17 @@ u_int8_t SpaControl::getEffectiveValue() {
214263
}
215264
// Serial.printf("GetEffectiveValue 2 %s\n", name);
216265
u_int8_t dependencyValue = getDependencyValue();
266+
if (dependencyGatedBySchedule) {
267+
// The schedule only ARMS this control (e.g. ozone): it turns on solely when its
268+
// dependency (the pump) is active AND its own schedule is on for this period. It
269+
// is never turned on by the schedule alone -- an armed-but-pump-off period is off,
270+
// and an unarmed period is off even while the pump runs.
271+
if (dependencyValue != SpaControlDependencies::SPECIAL_RETURN_VALUE_NOT_IN_EFFECT
272+
&& getScheduledValue() > 0) {
273+
return dependencyValue;
274+
}
275+
return 0;
276+
}
217277
if (dependencyValue != SpaControlDependencies::SPECIAL_RETURN_VALUE_NOT_IN_EFFECT) {
218278
return dependencyValue;
219279
}
@@ -364,13 +424,13 @@ void SensorBasedControl::applyOutputs() {
364424
}
365425

366426
void SensorBasedControl::setSetpoint(u_int8_t value) {
367-
// The setpoint is the "on" value of the normal schedule. Persist it so it
368-
// survives the override window and reboots.
427+
// A single "master" setpoint: apply it as the on-value of every scheduled period
428+
// (per-period setpoints are edited through the schedule editor instead). Persist so
429+
// it survives the override window and reboots.
369430
value = std::min(max, std::max(min, value));
370-
normalSchedule(normalSettings.percentageOfDayOnTime,
371-
normalSettings.numberOfTimesToRun,
372-
value,
373-
normalSettings.normalValueOff);
431+
for (u_int8_t i = 0; i < scheduleSettings.periodCount; i++) {
432+
scheduleSettings.periods[i].onValue = value;
433+
}
374434
// Clear any lingering override so the new persistent setpoint takes effect now.
375435
cancelOverride();
376436
persist();
@@ -398,7 +458,7 @@ void SpaStatus::updateStatusString() {
398458
control->jsonStatus["min"] = control->min;
399459
control->jsonStatus["max"] = control->max;
400460
// default override time
401-
control->jsonStatus["DO"] = control->normalSettings.overrideDefaultDurationSeconds;
461+
control->jsonStatus["DO"] = control->scheduleSettings.overrideDefaultDurationSeconds;
402462
control->jsonStatus["type"] = control->type;
403463
control->jsonStatus["ORT"] = control->getOverrideScheduleRemainingTime();
404464
control->jsonStatus["val_o"] = control->getOnState();
@@ -434,11 +494,21 @@ SpaControl *SpaStatus::findByName(const char *name) {
434494
void SpaStatus::setup() {
435495

436496
// Defaults until UI configures these values (native unit is Fahrenheit):
437-
pump->normalSchedule(50, 2, 1, 0);
497+
// Sane new-model default: pump on Low for 5 min out of every 30, all day.
498+
{ SpaSchedulePeriod pp; pp.startHour = 0; pp.cycleMinutes = 30; pp.onMinutes = 5; pp.onValue = 1; pp.offValue = 0; pump->setSchedule(&pp, 1); }
438499
// Always on; heat to a sane default setpoint (100F), keep above freezing (40F)
439500
// when scheduled off. The setpoint is persistent and editable from the UI.
440-
heater->normalSchedule(100, 1, 100, std::max(heater->min, (u_int8_t)40)); pump->neededBy(heater, 1, 1);
501+
heater->normalSchedule(100, 1, 100, std::max(heater->min, (u_int8_t)40));
502+
heater->scheduleSettings.overrideDefaultDurationSeconds = 3600 * 2; // different default
503+
504+
pump->neededBy(heater, 1, 1);
505+
441506
ozone->lockedTo(pump, SpaControlDependencies::SPECIAL_VALUE_ANY_GREATER_THAN_ZERO, 1);
507+
// Ozone follows the pump, but only during periods its own schedule marks "on".
508+
// Default: armed all day (= today's behaviour) until off-periods are scheduled.
509+
ozone->dependencyGatedBySchedule = true;
510+
ozone->normalSchedule(100, 1, 1, 0);
511+
ozone->scheduleSettings.overrideDefaultDurationSeconds = 3600; //default
442512

443513
Serial.println("About to iterate and load settings");
444514
// apply saved preferences

‎controller/HotTubUtils.h‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,27 @@ class SpaControlDependencies {
9999
};
100100

101101

102+
#define MAX_SCHEDULE_PERIODS 6
103+
#define SCHEDULE_SETTINGS_VERSION 2
104+
105+
// One time-of-day period. Periods tile the day: a period runs from its startHour
106+
// until the next period's startHour (the last runs to midnight). Within a period the
107+
// control cycles: ON for the first onMinutes of every cycleMinutes window. When
108+
// onMinutes >= cycleMinutes the control is always on (used for heater setpoints).
102109
typedef struct {
103-
// default is always off:
104-
u_int8_t percentageOfDayOnTime = 0;
105-
u_int8_t numberOfTimesToRun = 1; // we don't allow 0 here
106-
u_int8_t normalValueOn = 1;
107-
u_int8_t normalValueOff = 0;
110+
u_int8_t startHour = 0; // 0..23, when this period begins
111+
u_int16_t onMinutes = 0; // ON duration at the start of each cycle
112+
u_int16_t cycleMinutes = 60; // cycle length (>=1); onMinutes>=cycleMinutes => always on
113+
u_int8_t onValue = 1; // control value while ON (pump speed, heater setpoint, ...)
114+
u_int8_t offValue = 0; // control value while OFF
115+
} SpaSchedulePeriod;
108116

117+
typedef struct {
118+
u_int8_t version = SCHEDULE_SETTINGS_VERSION; // for forward migration / validation
119+
u_int8_t periodCount = 1; // 1..MAX_SCHEDULE_PERIODS
120+
SpaSchedulePeriod periods[MAX_SCHEDULE_PERIODS];
109121
u_int32_t overrideDefaultDurationSeconds = 60 * 20; // 20 minutes default
110-
} SpaSchedulerNormalSettings;
122+
} SpaScheduleSettings;
111123

112124
/**
113125
* A Scheduler that can be attached to any control
@@ -138,6 +150,16 @@ class SpaControlScheduler {
138150
*/
139151
void normalSchedule(u_int8_t percentageOfDayOnTime, u_int8_t numberOfTimesToRun, u_int8_t normalValueOn, u_int8_t normalValueOff);
140152

153+
/**
154+
* Replace the whole time-of-day schedule. Periods are sorted by startHour, the
155+
* first is forced to start at 00:00, count is clamped to [1, MAX_SCHEDULE_PERIODS],
156+
* and values are clamped to [min, max]. Does not persist (call persist() after).
157+
*/
158+
void setSchedule(const SpaSchedulePeriod* newPeriods, u_int8_t count);
159+
160+
/** Index of the period that covers the current time of day. */
161+
u_int8_t getCurrentPeriodIndex();
162+
141163
/**
142164
* Override normal schedule during this period
143165
* @param startTime
@@ -183,9 +205,14 @@ class SpaControlScheduler {
183205
u_int8_t max = DEFAULT_MAX;
184206

185207

186-
char configString[150]; // for JSON output
208+
char configString[700]; // for JSON output (holds the periods array)
187209

188-
SpaSchedulerNormalSettings normalSettings;
210+
// When true this control only follows a "locked-to" dependency (e.g. ozone->pump)
211+
// while its own schedule is ON: the schedule ARMS it rather than running it directly,
212+
// and it is never turned on by the schedule alone. See SpaControl::getEffectiveValue().
213+
bool dependencyGatedBySchedule = false;
214+
215+
SpaScheduleSettings scheduleSettings;
189216

190217
private:
191218
/**
@@ -200,12 +227,7 @@ class SpaControlScheduler {
200227
time_t overrideEndTime = now();
201228
u_int8_t overrideValue = SCHEDULER_DISABLED_VALUE;
202229

203-
// For normal schedule math:
204-
// These are normally set by calling normalSchedule():
205-
float onOffLengthPercentage = 100; //defaults based on values of variables above (always off)
206-
float onVsOff = 0; //defaults based on values of variables above (always off)
207-
208-
StaticJsonDocument<150> jsonConfig;
230+
StaticJsonDocument<700> jsonConfig;
209231
};
210232

211233
class SpaControl : public SpaControlScheduler, public SpaControlDependencies {

0 commit comments

Comments
 (0)