Thermostat Scheduler: Wrap-Around Bugs, Holds and Zones
EasyAI-assisted coding · AI-Assisted Debugging
Thermostat Scheduler: Wrap-Around Bugs, Holds and Zones
You've joined the comfort-platform team that owns the scheduling core of a smart thermostat. The household sets a weekly schedule; the thermostat decides what temperature to aim for at any minute. Customer support has escalated wrong-temperature reports, and two features are queued behind the fix. Temperatures are integer tenths of a degree Celsius (215 means 21.5 C).
Codebase
| File | What it does |
|---|---|
thermo/api.py | run(operations) entry point and the Dispatcher that routes each operation |
thermo/thermostat.py | Thermostat: schedule edits and status; stubs for holds and away mode |
thermo/schedule.py | WeeklySchedule: the seven days of periods and the lookups on them |
thermo/holds.py | Placeholder for the hold type (Part 2) |
thermo/clock.py | Parsing and formatting of moments such as "Mon 07:30", minute-of-week helper |
thermo/validation.py | Argument checks for setpoints, days and period lists |
thermo/models.py | The shape and vocabulary of status answers |
thermo/errors.py | ThermoError and every error code |
thermo/config.py | Constants (setpoint range, default away setpoint, default zone) |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh thermostat and returns one result per operation. Every operation is [name, ts, ...args].
tsis a moment:"Mon 07:30"(dayMon..Sun, 24-hour time) in week 0, or"w2 Mon 07:30"for week 2 (w0,w1, ... with no leading zeros).tsnever decreases from one operation to the next. The weekly schedule repeats every week; week 0 starts on Monday 00:00.- Errors are returned as
"error: <code>"strings, never raised. An unknown operation name returns"error: unknown operation"; a wrong number of arguments, an unparsablets, or an argument of the wrong type returns"error: invalid arguments". - A setpoint is an integer (not
true/false) from50to300inclusive; otherwise"error: setpoint out of range"(a non-integer isinvalid arguments). - A period is
["HH:MM", setpoint]("00:00".."23:59", two digits each).
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
set_schedule | day, periods | true | invalid arguments, setpoint out of range, duplicate period |
status | none | {"setpoint": int, "source": str} | no schedule |
set_schedulereplaces that weekday's list (an empty list clears the day). The list may be given in any order. All structure (list shape, day name,HH:MMtext, integer setpoints) is checked first, then every setpoint's range, then duplicate start times in the list. A failed call changes nothing.statusreports the setpoint in force attsand itssource, which is"schedule"in Part 1. If no weekday has any period, it returns"error: no schedule".
Worked example
[["set_schedule", "Mon 00:00", "Mon", [["06:30", 210], ["22:00", 170]]],
["status", "Mon 06:30"],
["status", "Mon 22:00"],
["status", "Tue 03:00"]]returns
[true, {"setpoint": 210, "source": "schedule"}, {"setpoint": 170, "source": "schedule"}, {"setpoint": 170, "source": "schedule"}]Part 1 — Fix the wrong scheduled setpoints (SUP-5108)
Goal: make status return the setpoint the household actually scheduled.
Support tickets:
- "My heating drops at 22:00 on Friday, but at 06:30 sharp on Saturday morning it still isn't back up until 06:31." (the period that starts exactly at the queried minute is not applied)
- "I only set Wednesday. On Monday the thermostat says *no schedule* instead of carrying on with last Wednesday's evening setpoint."
- "I entered my evening period before my morning one and the temperatures came out swapped."
Rules
- A period is in force from its start minute, inclusive, until the next period starts. The period starting exactly at the queried minute is the one in force.
- The schedule repeats weekly. Before the first period of the week, the last period of the previous week is still in force — even if several days in between have no periods. A week with a single period in total keeps that setpoint all week.
- Periods may be supplied in any order, on any day.
tests/test_schedule.py and tests/test_api.py contain failing tests for the first two tickets.
Part 2 — Holds and away mode (FEAT-912)
Goal: let people override the schedule from the wall dial and the app, and add an away mode.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
hold | setpoint | moment the hold ends, e.g. "Mon 22:00" | invalid arguments, setpoint out of range, away mode, no schedule |
hold_permanent | setpoint | "permanent" | invalid arguments, setpoint out of range, away mode |
cancel_hold | none | true | away mode, no hold |
set_away | setpoint | true | invalid arguments, setpoint out of range |
away_on | none | true | already away |
away_off | none | true | not away |
Rules
- Precedence, highest first: away mode, then the active hold, then the schedule.
statusreturnssource"away","hold"or"schedule"accordingly. In away modestatusworks even when there is no schedule. holdis temporary: it lasts until the first period start strictly afterts, counting across days and weeks (a period start is a start on any weekday). That moment is returned. A hold set exactly at a period start therefore lasts until the *following* start; with a single period in the whole week it lasts until that period starts again a week later ("w1 Mon 06:30"). The expiry is fixed when the hold is made — laterset_schedulecalls do not move it. At the expiry minute itself the hold is over and the schedule applies.hold_permanentlasts untilcancel_hold. It works with no schedule at all (statusthen reports the hold; without a hold or schedule it reportsno schedule).- A new hold replaces the current one. Nothing underneath comes back later.
cancel_holdends the active hold; if there is none — never set, already cancelled, or a temporary hold that has already ended — it returnsno hold.- Away mode is a single switch for the whole home, with its own setpoint (default
160;set_awaychanges it at any time, even while away, and takes effect immediately). While away,hold,hold_permanentandcancel_holdare rejected withaway mode. Existing holds are not touched: whenaway_offis called, the state is exactly what the clock says it should be — a permanent hold is back, a temporary hold is back only if its expiry has not passed yet.
Example (Mon-Fri schedule 06:30 210 and 22:00 170 already set)
[["hold", "Mon 12:00", 235],
["status", "Mon 21:59"],
["hold", "Mon 22:00", 190],
["away_on", "Tue 01:00"],
["status", "Tue 01:00"],
["away_off", "Tue 07:00"],
["status", "Tue 07:00"]]returns
["Mon 22:00", {"setpoint": 235, "source": "hold"}, "Tue 06:30", true,
{"setpoint": 160, "source": "away"}, true, {"setpoint": 210, "source": "schedule"}](The 190 hold made at 22:00 would have ended at Tue 06:30, which passed while away.)
Part 3 — Rooms (zones) (FEAT-955)
Goal: the product is moving to per-room zones. Each zone has its own schedule and its own hold; the household can also hold the whole home at once. Part 1 and 2 behaviour must still work.
New operation and extended operations (zone is a zone id; every zone-aware operation takes it as an optional last argument, defaulting to "main", which exists from the start):
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_zone | zone | true | invalid arguments, zone exists |
set_schedule, status, hold, hold_permanent, cancel_hold | as before, plus optional zone | as before | as before, with zone not found inserted after all argument checks (invalid arguments, setpoint out of range, duplicate period) and before away mode and every later error |
hold_home | setpoint | {zone: expiry} | invalid arguments, setpoint out of range, away mode, no schedule |
hold_home_permanent | setpoint | {zone: "permanent"} | invalid arguments, setpoint out of range, away mode |
Rules
- A zone id is 1-20 characters: a lowercase letter followed by lowercase letters, digits or
-.add_zonewith anything else isinvalid arguments. A new zone has an empty schedule and no hold. A zone argument that is not a string isinvalid arguments; a valid string naming no zone iszone not found. - Zones are independent: schedule, holds and
no scheduleare all per zone. A temporaryholdends at the next period start of that zone's own schedule. hold_homeapplies a temporary hold to every zone that currently has at least one period, each ending at its own zone's next start; it returns the expiries for exactly those zones (no scheduleif no zone qualifies).hold_home_permanentapplies a permanent hold to every existing zone. A zone added later is not affected by an earlier home hold.- The later hold wins, by order of operations (not by
ts; two holds at the same minute are ordered by which came first). A zone hold made after a home hold replaces it for that zone only; a home hold made after a zone hold replaces that zone's hold. Per Part 2 rule 4, nothing underneath comes back when a hold ends or is replaced. cancel_holdwith a zone ends only that zone's active hold, whether it came from the zone or from a home hold. There is nocancel_hold_home.- Away mode stays a whole-home switch: it covers every zone, including zones added while away, and
away_offrestores each zone exactly as its own clock says (Part 2 rule 6).
Example
[["add_zone", "Mon 00:00", "bedroom"],
["set_schedule", "Mon 00:00", "Mon", [["06:30", 210], ["22:00", 170]]],
["set_schedule", "Mon 00:00", "Mon", [["07:00", 190], ["23:00", 160]], "bedroom"],
["hold_home", "Mon 12:00", 230],
["hold", "Mon 12:30", 250, "bedroom"],
["status", "Mon 13:00"],
["status", "Mon 13:00", "bedroom"],
["status", "Mon 22:00"],
["status", "Mon 22:00", "bedroom"]]returns
[true, true, true, {"bedroom": "Mon 23:00", "main": "Mon 22:00"}, "Mon 23:00",
{"setpoint": 230, "source": "hold"}, {"setpoint": 250, "source": "hold"},
{"setpoint": 170, "source": "schedule"}, {"setpoint": 250, "source": "hold"}]Notes
clock.pyandvalidation.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["set_schedule","Mon 00:00","Mon",[["06:30",210],["22:00",170]]],["status","Mon 06:30"],["status","Mon 22:00"],["status","Tue 03:00"]]
- Output:
- [true,{"setpoint":210,"source":"schedule"},{"setpoint":170,"source":"schedule"},{"setpoint":170,"source":"schedule"}]
- Explanation
- A period is in force from its start minute (inclusive), so 06:30 already reads 210. After Monday's last period the 170 setpoint carries on into Tuesday morning.
Example 2
- Input:
- [...Mon-Fri 06:30/210, 22:00/170..., ["hold","Mon 12:00",235], ["status","Mon 21:59"], ["hold","Mon 22:00",190], ["away_on","Tue 01:00"], ["status","Tue 01:00"], ["away_off","Tue 07:00"], ["status","Tue 07:00"]]
- Output:
- ["Mon 22:00",{"setpoint":235,"source":"hold"},"Tue 06:30",true,{"setpoint":160,"source":"away"},true,{"setpoint":210,"source":"schedule"}]
- Explanation
- The first hold lasts until the next period start (22:00). A hold made exactly at 22:00 lasts until the following start, Tue 06:30, which passes while the home is away, so the schedule applies on return.
Example 3
- Input:
- [["add_zone","Mon 00:00","bedroom"], ...schedules for main and bedroom..., ["hold_home","Mon 12:00",230], ["hold","Mon 12:30",250,"bedroom"], ["status","Mon 22:00"], ["status","Mon 22:00","bedroom"]]
- Output:
- [..., {"setpoint":170,"source":"schedule"}, {"setpoint":250,"source":"hold"}]
- Explanation
- The home hold ends at each zone's own next start (22:00 for main). The later bedroom hold replaced the home hold in that zone only and runs until bedroom's 23:00 start.
Guidelines
- Python 3 standard library only
- Setpoints are integer tenths of a degree; there is no floating point anywhere
- Moments are strings like "Mon 07:30" or "w2 Mon 07:30"; ts never decreases from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Precedence is exact: away mode, then the active hold, then the schedule