@@ -60,49 +60,95 @@ SpaControl *SpaControlDependencies::getDependentControl() {
6060void
6161SpaControlScheduler::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
79121void 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}
89131void 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
130176u_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
155194bool SpaControlScheduler::isOverrideScheduleEnabled () {
@@ -176,11 +215,21 @@ time_t SpaControlScheduler::getOverrideScheduleElapsedTime() {
176215
177216
178217void 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
366426void 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) {
434494void 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
0 commit comments