Skip to content

feat(climate): climate schedules, plus fan only and front defrost modes - #441

Merged
joszz merged 7 commits into
mainfrom
feat/climate-schedules
Oct 10, 2026
Merged

joszz merged 7 commits into
mainfrom
feat/climate-schedules

Conversation

@joszz

@joszz joszz commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Pull Request

Summary

Climate schedules: switch climate on at a set time on chosen weekdays, or once, so the car is warm (or cool) when you leave. The saic gateway has charging and battery-heating schedules but none for climate, so GarageStack runs them itself. The climate popup also gains the gateway's two other climate modes: fan only and front defrost.

  • Schedules page (sidebar, built like Maintenance): a list of cards and one form to add, edit, switch off or delete a schedule.
    • Start time on a 24-hour clock (hour and minute selects, minutes in steps of five).
    • Weekday chips with Weekdays / Weekend / Every day shortcuts. No day picked runs once, then the schedule switches itself off.
    • Climate mode, temperature, seat heating and rear defroster, reusing the climate popup's controls.
    • Optional "only when it is cold or hot", judged by the car's outside temperature from the last 3 hours. Without a recent reading the schedule runs anyway.
  • Climate popup: mode chips (climate, fan only, front defrost) while climate is switched on, and a "Climate schedules · N active" row that links to the page.
  • Dashboard: an optional "Climate on" card with the next start, hidden by default.
  • Push notifications: one per run in a new "Scheduled climate" type, switchable per device. They say climate started, the car refused (with its reason), or the car did not answer. Skipped and missed runs send nothing.

How it runs

  • The Api keeps the schedules (/api/vehicles/{vin}/climate-schedules) and works out each next run on save. A schedule keeps its start time on the clock in the IANA time zone the browser had when it was made, so 07:30 stays 07:30 across daylight saving time.
  • The Worker looks for due schedules every 15 s and claims each one before running it, so a restart never runs a schedule twice. A run found more than 10 minutes late is recorded as missed instead.
  • It sends the commands one at a time: temperature first (with climate off the gateway only stores it), then the climate mode, then the extras. It stops at the first one refused or unanswered.
  • Only the Api publishes MQTT, so the Worker asks for each command on a new pg channel, vehicle_command_requested. The Api sends it through the same checks and one-at-a-time gate as the browser does, and handles it apart from its LISTEN loop so the command_result that frees the gate never queues behind it. The Worker waits for the gateway's answer itself.
  • The Api (edits) and the Worker (claims and outcomes) both write a schedule; a Version concurrency token keeps them from overwriting each other.

Shared pieces (reused rather than rewritten)

  • VehicleCommands moves to Core so the Worker can name a command's topic. VehicleCommandSender is now the one place a command is checked and published.
  • The Maintenance form classes become shared .form-stack/.form-row/.form-group/.form-control, and .maintenance-list becomes .status-card-list.
  • The trip-purpose buttons become ChipGroup, which also drives the mode chips and WeekdayPicker.
  • useDistanceField becomes useMeasureField(units, quantity), used for the °F/°C thresholds as well.
  • The climate popup's slider and switch rows become RangeControl and DetailListSwitch. Its temperature and seat labels move to useClimateLabels.
  • StatusCard takes an optional note line under its value.
  • Each theme now declares color-scheme, so the browser's own widgets (date pickers, select dropdowns, the icons inside inputs) follow dark mode. This also fixes the Maintenance date picker.
  • tzdata is named in the Api, Worker and all-in-one images. All three bases ship it today; this keeps time zones working if a base drops it.

Type of Change

  • Bug fix (native pickers and dropdowns were drawn light in dark mode)
  • New feature
  • Refactor / cleanup
  • Documentation
  • Dependency update
  • Other

Related Issues

None.

Checklist

  • Tests added or updated: daylight-saving and multi-zone calendar cases, skip conditions, command plan, validation per error code, repository concurrency, request handler, answer waiter, runner (each outcome and its push), service (claim, missed, interrupted, one run per car), end to end in AuthenticationFlowTests; frontend specs for the utils, API, store, ChipGroup, WeekdayPicker, RangeControl, TimeOfDaySelect, the form modal and the climate popup.
  • Linting passes (pnpm lint, pnpm type-check, Prettier, dotnet build, dotnet format --verify-no-changes, no pending model changes)
  • No secrets or personal data committed (synthetic VINs only)
  • Responsive on mobile (checked 390 and 1440 px in light and dark, no horizontal overflow)
  • i18n keys added for any new UI strings (en and nl in parity, notification texts in both resx files)
  • If unraid/garagestack.xml changed (it did not; no new environment variables)

Verification

  • 889 backend and 815 frontend tests pass.
  • End to end against PostgreSQL 18 and Mosquitto in Docker, with the real Api and Worker and a fake gateway answering each /set on /result:
    • A one-off schedule fired within seconds of its time, the Api published temperature 22, climate on, then seat level 1, the schedule switched itself off, and a "Climate is on" notification was written.
    • Against a gateway refusing climate (Failed: vehicle offline), a daily schedule stopped before the rear defroster, recorded failed with the car's reason, moved to the next day, and sent "Climate did not start".
  • Both migrations (AddClimateMode, AddClimateSchedules) apply on PostgreSQL 18.
  • Demo mode: screenshots of the page, form, popup and dashboard card at both widths and themes; Maintenance and the trip log look unchanged after the CSS moves.

Known limits

  • The gateway's answers carry no request id. A command sent by hand at the moment a schedule runs can count as that run's answer: the command still reaches the car, only the recorded outcome can be off.
  • In English the dashboard card shows "7:30 AM · Sat" and cuts the day short on narrow cards, as the climate card does; the tooltip has the full text.
  • Demo mode has no Worker, so schedules can be edited but never run.
  • Not addressed here: the Worker logs "Unmapped telemetry topic" for every /set the Api publishes. This already happens on main for manual commands.

Upgrade note

Two migrations run on Api start. Update the Api and Worker together: the Worker's scheduled commands need an Api that LISTENs on vehicle_command_requested.

🤖 Generated with Claude Code

joszz and others added 7 commits October 9, 2026 12:58
…hedules

Backend:
- VehicleCommands moves to Core so the Worker can name a command's topic too.
  Validate now returns the English message; the Api adds the code.
- VehicleCommandSender holds the topic, value check and gated publish that the
  command endpoint did inline, so other callers send commands the same way.

Frontend:
- Maintenance form classes become shared .form-stack/.form-row/.form-group/
  .form-control, and .maintenance-list becomes .status-card-list.
- The trip purpose buttons become a reusable ChipGroup (one-of-N or
  several-of-N, aria-pressed), with the purpose colours as modifiers.
- useDistanceField becomes useMeasureField(units, quantity), so a temperature
  field round-trips the same way a distance does.
- The climate popup's slider and switch rows become RangeControl and
  DetailListSwitch. The sliders now carry an aria-label, and the seat label
  follows the slider while dragging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway takes four values on climate/remoteClimateState/set (on, off,
front, blowingonly) and reports the same four back, but GarageStack only
accepted on/off and folded the other two into ClimateOn.

- The climate command accepts front and blowingonly.
- New TelemetrySnapshot.ClimateMode (migration AddClimateMode) keeps the mode
  the gateway reports; ClimateOn is unchanged.
- The climate popup shows the mode as chips while climate is switched on, sends
  the chosen mode, and confirms it from the reported mode. Fan only and front
  defrost take no temperature, so the slider is disabled for them (the gateway
  would restart normal climate to apply one).
- The card names a running fan only or front defrost mode.
- The popup's temperature and seat labels move to useClimateLabels, which the
  schedule form will share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switch climate on at a set time on chosen weekdays, or once (a schedule with
no day runs once and then switches itself off). A schedule sets the climate
mode and temperature, seat heating and the rear defroster, and can skip mild
days by the car's outside temperature. The gateway has no climate schedule of
its own, so GarageStack runs them.

Backend:
- ClimateSchedule (migration AddClimateSchedules) with a repository both
  processes write through; a Version concurrency token keeps the Api's edits
  and the Worker's claims and outcomes from overwriting each other.
- ClimateScheduleCalendar keeps the start time on the clock in the zone the
  browser had, across daylight saving time (a skipped time runs just after the
  jump, a repeated one runs once).
- /api/vehicles/{vin}/climate-schedules (list, create, update, delete).
- The Worker claims due schedules every 15 s before running them, records a
  run found 10+ minutes late as missed, skips while the engine runs or on a
  mild day (outside temperature from the last 3 hours only), and sends the
  commands one at a time. It asks the Api to send each one on the new
  vehicle_command_requested channel, so they pass the same checks and gate as
  the browser's, and waits for the gateway's answer itself.
- One push per run in the new climate-schedule category (en and nl).
- tzdata named in the Api, Worker and all-in-one images.

Frontend:
- Climate schedules page (sidebar), built like Maintenance: a list of cards
  and one form for add, edit, switch off and delete, reusing the climate
  popup's controls, the shared chips (WeekdayPicker) and form classes.
- The climate popup links to the schedules; an optional dashboard card shows
  the next start.
- StatusCard takes an optional note line under its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither theme told the browser its colour scheme, so the browser drew its
own widgets light whatever the theme: the time picker popup of the schedule
form, the Maintenance date picker, select dropdowns and the clock and
calendar icons inside inputs. Each theme now sets color-scheme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser's time picker popup is drawn by the browser itself: no
stylesheet reaches its selection colours, so even with the dark colour
scheme the chosen minute showed as a light grey block. TimeOfDaySelect
shows the time as hour and minute selects (minutes in steps of five, with a
saved minute between steps kept), plus AM/PM when the interface language
uses a 12-hour clock. The model stays a 24-hour HH:mm string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimeOfDaySelect drops the 12-hour clock with its AM/PM select: hours 00 to
23 in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dotnet format --verify-no-changes flagged the request object in
ClimateSchedules_RoundTripThroughTheApi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joszz
joszz merged commit 95ad2e6 into main Oct 10, 2026
24 checks passed
@joszz
joszz deleted the feat/climate-schedules branch October 10, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant