Skip to content

Nixie continuous schedule: circuit force-turned-off ~1 minute after local midnight due to stale cstate.endTime #1192

@pawmmm

Description

@pawmmm

Summary

A schedule that is continuously active (for example a 12:00 AM → 12:00 AM schedule on all days, which the engine treats as a 24-hour rolling window) on a Nixie-managed circuit causes the circuit to be force-turned-off approximately one minute after the local midnight rollover. The off-event comes from checkCircuitEggTimerExpirationAsync, not from the schedule logic deciding the schedule has expired.

Concrete observation (May 22 2026, user @pawmmm)

May 22 00:00:02 ... warn: dt:Fri May 22 2026 00:00:02 GMT-0600 lat:undefined lon:undefined Not enough information to calculate Heliotrope.
May 22 00:01:02 ... info: NCP: Setting Circuit Pool Filter to false
May 22 00:01:02 ... info: NCP: Setting Pump IntelliFlo VSF to 0 RPM.
May 22 06:37:50 ... info: 192.168.1.135 PUT /state/circuit/setState {"id":6,"state":true}

Schedule: single schedule, all 7 days, startTime 12:00 AM, endTime 12:00 AM. The pump stayed off ~6.5 hours until the user manually re-armed it via dashPanel.

(The Heliotrope warning at 00:00:02 is unrelated — it is logged once when the date setter resets _warningSuppressed on day-change and is only a red herring here.)

Root cause

Two separate but reinforcing bugs in the cstate.endTime refresh path:

Bug A — SystemBoard.syncScheduleStates never refreshes cstate.endTime after the schedule's window advances

controller/boards/SystemBoard.ts lines 3716–3735 (commit 8961410):

public syncScheduleStates() {
    try {
        ncp.schedules.triggerSchedules();
        for (let i = 0; i < state.schedules.length; i++) {
            let schedIsOn: boolean;
            let ssched = state.schedules.getItemByIndex(i);
            let scirc = state.circuits.getInterfaceById(ssched.circuit);
            let mOP = sys.board.schedules.manualPriorityActive(ssched);
            if (scirc.isOn && !mOP && ssched.scheduleTime.shouldBeOn) schedIsOn = true
            else schedIsOn = false;
            if (schedIsOn !== ssched.isOn) {
                ssched.isOn = schedIsOn;
                sys.board.circuits.setEndTime(sys.circuits.getInterfaceById(ssched.circuit), scirc, scirc.isOn, true);
            }
            ssched.emitEquipmentChange();
        }
    } catch (err) { logger.error(`Error synchronizing schedule states`); }
}

setEndTime is called only when schedIsOn !== ssched.isOn. For a 24-hour-rolling schedule ssched.isOn is always true, so the transition condition is never satisfied. Meanwhile ssched.scheduleTime.endTime advances at every midnight rollover (per ScheduleTime.calcSchedule — see controller/State.ts:1341), but the circuit-level cstate.endTime stays frozen at the value computed when the circuit was last turned on. After midnight, cstate.endTime is in the past relative to now, and checkCircuitEggTimerExpirationAsync (in controller/nixie/circuits/Circuit.ts:433) fires:

if (typeof cstate.endTime !== 'undefined') {
    if (cstate.endTime.toDate() < new Timestamp().toDate()) {
        await sys.board.circuits.setCircuitStateAsync(cstate.id, false);
    }
}

The 60-second gap between 00:00:02 (heliotrope warning marking the date roll) and 00:01:02 (the off command) matches the stale cstate.endTime being yesterday's 00:00:59.999 and the next 3-second processStatusAsync poll falling after 00:01:00.

Bug B — IntelliCenterBoard.syncScheduleStates has a dead refresh loop

controller/boards/IntelliCenterBoard.ts lines 4810–4820:

for (let i = 0; i < scheds.length; i++) {
    let ssched = scheds[i];
    if (!ssched.isOn || ssched.disabled || !ssched.isActive) continue;
    let c = circs.find(x => x.state.id === ssched.circuit);
    if (typeof c === 'undefined') {
        let cstate = state.circuits.getInterfaceById(ssched.circuit);
        c = { state: cstate, endTime: ssched.scheduleTime.endTime.getTime() };
        circs.push;                                   // ← missing function call
    }
    if (c.endTime < ssched.scheduleTime.endTime.getTime()) c.endTime = ssched.scheduleTime.endTime.getTime();
}
for (let i = 0; i < circs.length; i++) {
    let c = circs[i];
    if (c.state.endTime.getTime() !== c.endTime) {
        c.state.endTime = new Timestamp(new Date(c.endTime));
        c.state.emitEquipmentChange();
    }
}

circs.push; is a reference to the method, not a call. c is never added to circs, so the second loop iterates over an empty array and never refreshes cstate.endTime. The IntelliCenter board has the right idea about how to fix Bug A but the loop is broken.

Proposed fix

Two changes in one PR:

  1. SystemBoard.syncScheduleStates: also refresh cstate.endTime when a schedule is active on an on-circuit and its scheduleTime.endTime has advanced past cstate.endTime.

  2. IntelliCenterBoard.syncScheduleStates: fix circs.push;circs.push(c); so the refresh loop actually runs.

Out of scope (mentioning for awareness, not proposing to fix here)

Once Bug A is fixed, the egg-timer expiration no longer fires spuriously for continuous schedules, and the secondary "stuck ssched.triggered" behavior in controller/nixie/schedules/Schedule.ts:120-188 (where a force-turned-off circuit can't be re-armed by the still-active schedule because triggered was left at true) becomes moot for this scenario. Whether the trigger-loop should also reset triggered on external off-events is a design decision tangled with Manual OP semantics; happy to file a follow-up issue if maintainers want.

Environment

  • njspc commit 8961410 (master, 2026-05-15)
  • Reported and traced by @pawmmm on a Raspberry Pi running v9.1 against an IntelliFlo VSF + Core55 SWG + Raypak heater stack

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions