Versions and migrations
Read /api/v1/info.api_version before consuming snapshots. Its SemVer contract is independent of service_version and the route prefix:
- major: breaking change to a response shape or required field. API 2.0.0 makes previously fabricated weather values nullable; clients must handle this migration, including the separately maintained Home Assistant integration. The route prefix does not change, and there is no parallel endpoint returning fabricated values for API 1 clients. Deprecated compatibility fields remain in this release; removing them or the legacy alias requires a separately documented future migration.
- minor: additive field on a response, or new endpoint. No bump to the path prefix; integrators can rely on extra fields being ignorable.
- patch: data-correctness fix with no shape change.
The shape of each /api/v1/* GET response is locked at build time by insta snapshot tests in src/api/snapshot_tests.rs. Any change that mutates the JSON body fails CI until a maintainer acknowledges the diff, which is the moment api_version gets bumped. A collection’s per-entry shape is locked only where a fixture populates an entry; zones[] is locked by its own fixture, which carries both an empty zone and a fully populated one, and water_budgets[] likewise by a fixture carrying a default row and a populated one.
Deprecated on v1
These compatibility fields were deprecated during API 1 and remain in API 2.0.0. Snapshot fields carry a DEPRECATED (0.9.0) note in src/model/snapshot.rs; a test pins their reader counts so they cannot gain new consumers. The last column records intended future removal, not a change in API 2.0.0.
| field | on | what it says now | future removal |
|---|---|---|---|
zones[].hex | /irrigation/snapshot | always ""; the original deployment’s OpenSprinkler MAC suffix | dropped |
iu_enabled | /irrigation/snapshot | always false; Irrigation Unlimited is gone | dropped |
iu_suspended | /irrigation/snapshot | always false; the engine’s hold is skip_check.is_paused and pause_until_epoch | dropped |
ha_reachable | /irrigation/snapshot | last Home Assistant poll succeeded; always true on the native path | dropped; per-source reachability is GET /health sources[].status, the snapshot’s age is last_refresh_epoch |
override_helpers_present | /irrigation/snapshot | a persistence database is mounted (the name predates 0.7.22) | dropped; controls_persisted says the same |
ha_adoption_awaiting_config | /irrigation/snapshot | always false since 0.9.0 removed the adoption pass | dropped |
water_budgets[].mode_active | /irrigation/snapshot | always true | dropped |
zones[].today_run_minutes | /irrigation/snapshot | always null; nothing produces it | dropped |
/irrigation/shadow/snapshot, /irrigation/shadow/diff | endpoints | always {"shadow":"disabled"}; shadow-native mode is gone | absent |
deployment.shadow_native | config | read by nothing | dropped |
run_sequence_now | POST /irrigation/action kind | unknown since 0.9.0 (answers 400) | absent |
POST /wizard/test_source | endpoint | structural validation only; nothing in the UI calls it | absent |
Migration notes
2.3.0 (LocalSky 0.9.1) adds extra forecast tracks, inclusive forecast-window queries, and the hourly forecast archive. See Weather and forecasts.
2.4.0 (LocalSky 0.9.2). Privileged /health and the health section of
/diagnostics add optional sources[].error: {at_epoch, failure}. failure
contains a stable code, operation, message, next_step, and available
evidence (http_status, response_format, entity_id, io_kind, os_code,
tls_reason, line, column, timeout_ms, limit_bytes). causes retains separate failed
attempts, including HA bulk and individual-entity recovery. Fields without
evidence are omitted. No token, configured URL, response body or redirect
location is included. A successful poll clears the current error without
changing measurement freshness. These records are in memory for this boot;
the existing diagnostic log tail retains recent failures after recovery.
Streaming sources use the same bus record; partial forecast failures remain
visible between forecast refreshes.
Anonymous liveness responses continue to omit source details.
Codes identify client-observed failures, not hidden server exceptions.
LS_SOURCE_DIAGNOSTIC_MISSING explicitly identifies an adapter instrumentation
defect; it must not be interpreted as a diagnosis of the remote source.
API failures also carry request: {id, method, route} and the matching
X-LocalSky-Request-Id response header. Existing statuses and error fields are
preserved. Controller, storage, backup/restore, advisor and update failures add
optional diagnostic records; forecast tracks add an optional diagnostic.
Available evidence now includes SQLite extended codes, provider codes, field,
resource and item identity. omitted_causes reports bounded cause truncation.
See operational error codes for the diagnostic contract.
2.2.0 (included in LocalSky 0.9.1). Decision-trace rule entries add nullable
overridden_by and overridden_detail. A rule that crossed its threshold
keeps outcome: "fired" when a later scoped decision overrides it. To find
the deciding rule, require outcome == "fired" and no overridden_by value;
use the trace’s final verdict for the overall decision. Older stored traces
lack these annotations and cannot recover erased override history. Route
URLs and API major 2 are unchanged.
2.1.0 (released with 0.9.0). History run records add nullable
session_id. One watering job retains its identity across cycle/soak segments,
observer reconciliation, and morning catch-up after restart. Legacy records
remain null; proximity in time does not prove they belong to the same job.
Clients may ignore the new field. When computing applied water, use the union
of valve-open intervals per zone; summing session or observer records can count
overlapping water twice. Command provenance is stored separately and supplies
no applied-water credit.
Daily and hourly forecast entries add et0_reported. A positive legacy
et0_in remains valid; zero is supported only when et0_reported is true.
This preserves old numeric response shapes and does not reinterpret missing
zeros in existing caches. Daily entries also add nullable wind_mean_2m_ms
and humidity_mean_pct; peak wind and afternoon RH retain their separate fields.
The legacy heat multiplier fields now remain 1.0 because reference ET already
contains atmospheric demand. Soil-forecast status uncalibrated retains the
relative probe reading but supplies no unsupported future percentage curve.
2.0.0 (released with LocalSky 0.9.0). Breaking response change: missing weather evidence is null, including precipitation. Clients must accept null, render unknown, and exclude it from calculations. A reported 0°F, calm wind, zero humidity, or a fully covered dry rain interval remains numeric zero.
| Response object | Newly nullable numeric fields |
|---|---|
Forecast snapshot daily[] | temp_max_f, temp_min_f, wind_max_mph, humidity_pct, precip_sum_in |
Forecast snapshot hourly[] | temp_f, wind_mph, humidity_pct, precip_in |
Irrigation forecast | wind_max_today_mph, temp_min_24h_f, temp_max_3day_f, humidity_now_pct, heat_index_now_f, heat_index_max_3day_f, rain_today_om_in, rain_tomorrow_in, rain_3day_in, rain_3day_weighted_in, rain_7day_weighted_in, rain_next_4h_in, rain_intensity_in_hr |
Irrigation skip_check | forecast_in, rain_today_forecast_in, rain_3day_weighted_in, rain_7day_weighted_in, rain_next_4h_in, rain_intensity_now_in_hr |
Irrigation seven_day_verdicts[] | temp_max_f, temp_min_f, precip_in |
Irrigation water_budgets[] | expected_rain_mm |
Rain totals require valid amounts covering their requested intervals; gaps, overlaps, and missing samples cannot prove a dry window. Multi-day totals align to the configured local calendar and use the provider’s available daily horizon. Temperature and wind safety windows likewise require coverage. Incomplete current conditions hold watering. An enabled rain gate with missing amount evidence reports that absence; measured-dry soil retains its documented ability to demote soft forecast recommendations.
The additive skip_check.planning_forecast_unavailable lists zones whose automatic watering plan lacks complete next-24-hour rain evidence. Their protected planning_forecast gate applies to both weekly and soil scheduling and survives convenience Force. Explicit manual durations retain the separate manual-run and schedule-waiver policy. seven_day_verdicts[].rain_evidence_incomplete identifies incomplete rain evidence, and water_budgets[].soil_deferred_kind may now be "forecast_unavailable". Soil projections stop at unknown evidence. forecast_credit_mm remains a numeric amount actually applied to the balance: zero with source "unavailable" when required rain evidence is missing, accompanied by the planning hold where applicable; source "none" still means no forecast credit was requested.
Today’s measured rain and recent measured-rain backstops accept observation-grade gauge or radar evidence. Forecast totals and model archives remain separately identified; neither can turn the hard measured-rain gate on. Rhai forecast-rain inputs use () for missing evidence rather than zero. A script that cannot handle its inputs holds watering with the script’s reason.
Endpoint URLs remain under /api/v1. Install a LocalSky Home Assistant companion supporting API major 2 before the service upgrade, then reload that integration to refresh manifest paths. The private forecast cache now uses schema 3; bare caches and schema 1/2 caches are discarded because their synthetic zeros cannot be distinguished from real readings. Forecast-dependent decisions remain unavailable until fresh usable evidence arrives. This cache refresh does not rewrite recorded watering history.
The unreleased 1.31.0 additions below are included in 2.0.0.
1.31.0 (intermediate local candidate). Additive: /info.build_revision identifies the compiled source. The irrigation snapshot adds restart_required and restart_reasons for a persistent hold that clears on process restart, flow_connected for actual meter evidence, and flow with nullable rate_gpm, rate_source_id, total_gal_today, and total_source_id. A supported but unwired meter supplies no reading; an instantaneous rate does not imply a cumulative total. HACS flow descriptors use these resolved paths; existing installations need one LocalSky integration reload to pick up the changed descriptor paths. skip_check.soil_probe_configured records probe bindings, and soil_probe_holds carries independent per-zone data holds even when another gate wins the headline. skip_check.script_hold is null or an object with id, name, and reason for the first enabled script hold, including a script evaluation failure. It remains available when an earlier gate wins the headline and still binds manual schedules with a weather waiver. Existing v1 fields retain their types.
1.29.0 (0.9.0). Minor: additive fields and the deprecation record above. On the irrigation snapshot: today_window (start, finish, kind of pre_dawn or post_sunrise, min_temp_f; the watering window chosen for today, after sunrise on a freezing dawn), next_run_state (at, no_legal_day, no_sunrise, no_location) with next_run_day_offset, restriction_allowed_days (the weekdays the rules allow, Sun=0); on skip_check: wind_window_max_mph, run_window, window_min_temp_f, watered_days; on water_budgets[]: dormant; on zones[]: controller_id, throughput_mm_hr, ledger_running, running_observed_epoch (when the controller took the running reading, null when it was read on demand). Manifest: wet_bulb_f now publishes on any install whose merge owns a temperature, not only one with a LAN station, because it is derived from the merged temperature and humidity whoever owns them; wind_lull_mph and rain_in_last_min still need a station that reports them. on run records: note, volume_gal, controller_id, applied_mm, cycle_index, cycle_count. GET /info gains location_configured. GET /health gains location_configured and a per-source note, and ?strict=1 answers 503 unless the status is ok. New endpoint: GET /diagnostics. Config: schema_version is 2 (see Configuration).
1.27.0 (the soil scheduling model). Minor, following the 1.25.0 precedent: additive fields, and a behavior change only for zones opted into the soil model. zones[].bucket_mm and zones[].math.bucket_mm gain their producer: the soil model’s evidence replay computes the deficit for every zone with a species and a soil texture, whichever model governs, and publishes it under the field’s documented sign (negative = needs water). null still means no bucket could be derived (a the environment zone list (removed in 0.9.0) list with no per-zone agronomy), so the 1.25.0 rule stands: null is the unknown, never a fabricated zero. The manifest’s capability-gated <slug>_soil_bucket descriptor and the MQTT bucket sensor publish again on zones that carry a value; manifest schema is unchanged, because the gate was already value-based.
Additive fields on each water_budgets[] row: scheduling_model ("weekly" or "soil"; empty string on JSON from an older producer), soil_depletion_mm / soil_taw_mm / soil_raw_mm (the replayed deficit and the zone’s capacity and trigger, mm; null where no bucket could be derived), soil_due (depletion crossed the trigger), soil_planned_seconds (under the weekly model, the shadow figure: what the soil model would water today; under the soil model, what today_seconds starts from, with window admission already applied: 0 on a window-deferred morning, soil_deferred_reason carrying the hold, while the pre-admission refill desire stays recoverable from soil_depletion_mm), soil_deferred_reason (the hold that zeroed a due soil zone), and soil_ceiling_binding (an operator-set weekly target clamped today’s refill).
Additive config: engine.scheduling_model (weekly or soil; an absent key means the operator never chose, and the install follows the shipped default, soil today. The key is omitted from GET /config while unset, so a round-tripped body cannot stamp the default in as an explicit choice; the setup wizard writes soil for new installs at apply time) and ZoneConfig.scheduling_model (null = the engine default), both on GET/PUT /api/config, the config schema, and the per-field apply path. The behavior change is scoped to zones the soil model governs: their today_seconds / today_reason / session_capped come from the soil plan (refill sizing, defer by deficit, window admission, the weekly-ceiling clamp), and three forward-rain gates plus the heat-advisory extension are inert for them. Weekly-governed rows are byte-identical to 1.26.0 apart from the additive fields and one declared value change, with the sizing math golden-pinned in src/engine/budget.rs: the run-evidence fetch widened from 8 to 15 days to cover the soil replay window, and last_run_epoch (on the water_budgets[] row, on zones[], and behind the zone detail’s last-ran line) reduces over all fetched rows, so a zone whose newest run ended 8-15 days ago now reports that run’s end where it read 0. Planned seconds, reasons, session spacing, and the forecast credit are unchanged (the session interval is at most 7 days). No HACS integration change is required: the new fields are ignorable, and the returning <slug>_soil_bucket entity is the same manifest descriptor 1.25.0 documented.
1.26.0 (single-day rain stops out-crediting the soil). Minor, following the 1.25.0 precedent. Three additive fields on each water_budgets[] row: observed_rain_credited_mm (the trailing observed rain the balance actually offset against the weekly target, each day held to the cap before summing; equal to observed_rain_mm whenever no single day exceeded it), rain_credit_cap_mm (the per-day rain-credit cap in effect, mm; 0 on JSON from an older producer means unknown/legacy, no cap applied), and rain_cap_inferred (true when the cap was derived from the zone’s soil texture and root depth rather than set by the operator). observed_rain_mm keeps carrying the RAW trailing 7-day sum, unchanged.
The behavior change, with no shape change: each day of observed rain, and each day of the forward forecast credit, is capped at the zone’s root-zone capacity (TAW = (field capacity - wilting point) x root depth, from the soil catalog and the species’ default or overridden root depth), because rain beyond that in a single day drains past the roots and never becomes plant-available. A 1.2 in storm day on sand now credits about 0.35 in instead of settling a 1.0 in week outright, so water_budgets[].today_seconds and today_reason move for storm weeks; a week whose rain never exceeded the cap on any day settles bit-for-bit as before, including the exact today_reason string. When a day did clip, the covered reason names both figures (“1.20" fell, 0.35" counted”) and the tuning report’s observed-rain line gains “; N.NN in counted after the soil cap”.
Additive config: ZoneConfig.rain_credit_cap_in (inches, 0.05..=5.0, null = derived from soil texture and root depth) rides GET/PUT /api/config and the config schema, with the zone editor field to match. New validation error zone_rain_credit_cap_range gates whole-config writes; POST /api/v1/config/zones/apply accepts the field with the same band; a value already on disk is clamped into range at load, the sessions_per_week treatment. No HACS integration change is required: no manifest entity carries a balance term, and the additive fields are ignorable.
1.25.0 (the engine stops reading Home Assistant; the soil deficit stops being fabricated). Minor, following the 1.18.0 honest-unknowns precedent. Adds ha_adoption[] to the irrigation snapshot, one entry per retired Home Assistant helper; empty on every standalone install. Each entry carries entity, outcome, target, adopted_value, previous_value, epoch, and the additive observed_value, which is set only where a threshold helper sat outside the range LocalSky can represent and was adopted at the nearest end. outcome is one of adopted, not_found, unreadable or kept_local (LocalSky’s own store already held an operator answer). Every outcome retires that entity’s read. Additive controls_persisted on the same snapshot: true when a persistence database is mounted, i.e. the four operator controls have somewhere to land. The migration notice reads it to tell a control that can never be adopted here apart from one that was not answering when the pass looked; absent reads false. Additive ha_adoption_awaiting_config on the same snapshot: true while the pass cannot run because the install has no localsky.toml to record it in (zones from the environment zone list (removed in 0.9.0), no config file), so every helper read is still live; absent reads false.
Three fields become nullable on the irrigation snapshot: zones[].bucket_mm, zones[].math.bucket_mm and zones[].today_run_minutes. The two bucket fields’ only producer was the Home Assistant entity sensor.smart_irrigation_<slug>, which the engine no longer reads for any purpose, so the old bare number published a hardcoded 0.0 on every install as though it were a measurement. today_run_minutes has no producer on any install either: nothing sums a zone’s valve-open minutes since local midnight, so it is null everywhere. null is the documented unknown; all three fields are still present in the response. A client that treated the old 0.00 as data was reading a defect.
No HACS integration change is required, and there is one thing worth adopting. The integration builds entities from GET /api/v1/sensors/manifest, and manifest schema goes to 1.6. The per-zone <slug>_soil_bucket descriptor is now capability-gated on the value being present, the same rule water_level_pct and the per-zone soil quartet already take, and so is the per-zone <slug>_run_today descriptor, gated on today_run_minutes; since nothing produces that figure, a Home Assistant install loses the <zone> run today sensor, which recorded a fabricated 0 into long-term statistics.
Additive in 1.6: min, max and step on number descriptors. The three threshold entities (max_wind_mph, min_temp_f, rain_skip_in) carry the range the server enforces, filled from the same function POST /api/irrigation/action checks a set_threshold against, so the entity cannot offer a value the write path refuses. The shipping integration was built against 1.5 and builds those three from fixed ranges of its own, 0 to 50 mph, 20 to 60 F and 0 to 1 in, all inside what the server accepts (0 to 50 mph, 20 to 70 F, 0 to 10 in), so no value its sliders offer is refused; a write outside the server’s range from any other client is answered 400 with the range in the message. That refusal is new: before the migration, set_threshold passed any number straight to the input_number helper. Nothing produced a deficit at this version (the 1.27.0 soil model later reintroduced a producer, and the value-based gate brings the sensor back on zones that carry a value), so that sensor stopped being advertised and no permanently unavailable entity was registered. A manifest-driven value of null already reads unavailable, which is what a gated-in sensor would show. On the HACS path the existing <slug>_soil_bucket entities become unavailable and can be deleted. MQTT discovery gates the same way and also cleans up after itself: an earlier version published the bucket sensor retained, carrying a fabricated 0.00, so skipping the publish would have left Home Assistant holding that value forever. LocalSky now publishes an empty retained payload to homeassistant/sensor/<node>/zone_<slug>_bucket_mm/config and to its state topic whenever no deficit exists, which removes the entity and drops the stale value from the broker.
One additive field: zones[].smart_suppressed, an object { weekdays: [0..6], schedules: [name], active_today: bool }, or null. It is set when an enabled Override manual schedule suppresses smart dispatch for that zone, so a client can say which days are affected. Display only; the suppression behavior itself is unchanged.
Behavior changes with no shape change:
zones[].math.kccomes from the native species catalog (kc_at_doy_lat, hemisphere aware) using the zone’s configured species, not from an entity attribute.water_budgets[]no longer lets HAinput_numberhelpers outrank LocalSky’s ownweekly_budget_inandsessions_per_week.- The 24-hour rain-defer gate weights forecast rain by precipitation probability, and reads the configured
engine.session_rain_defer_ininstead of a compile-time constant. Both makewater_budgets[].today_secondsandtoday_reasonmove for the same weather. - A smart-morning dispatch that fails now writes a
skippedrun row carrying the controller’s error text, soGET /api/v1/history/runsshows failed mornings. That row is excluded from the boot dedupe, so a restart inside the catch-up window can still dispatch a morning whose every row is a dispatch failure. A scheduler skip row (a per-zone verdict, a manual stop, a missed window) is not excluded and still marks the morning handled. The dedupe’s other arm is unchanged and day-wide: two or more zones with a completed row still mark the whole morning handled, so a sequence that failed partway through does not re-dispatch its remaining zones until the next window. - Dispatch skips any zone whose snapshot reports
running == truewithrunning_known == true, on the scheduled path and the catch-up path alike, so an open valve is never commanded open again.running_known == false(a fire-and-forget controller such asmqtt_command) is not treated as running. The claim is confirmed against the controller before the zone is dropped from the morning: a cloud controller is polled on the interval it declares, so a zone whose run ended in the minute before would otherwise be skipped for the day. A controller that cannot answer keeps the claim. ZoneConfig.sessions_per_weekis constrained to1..=7.PUT /api/configanswers422with the new validation errorzone_sessions_per_week_range, andPOST /api/v1/config/zones/applyrefuses an out-of-range value the way it refuses other out-of-band fields. Sessions space atfloor(7 / sessions_per_week)days, so a larger value yielded a zero-day interval and disabled the gate that stops a zone watering twice in one day. A value already on disk is clamped at read time rather than made unloadable.zones[].math.cap_bindingnow reports that the per-run ceiling is what set tonight’s minutes:scheduled_secondsequalsmax_duration_secondsand some stage wanted more than that (the weekly allocator’s ideal session, the seasonal dial, or a condition-rule multiplier). It isfalsewhenever no run is planned, so a zone held at zero by spacing, a rain defer, budget mode off, or an Override schedule does not read as shorted by its cap. Before this release the field wasraw_seconds > max_duration_seconds, andraw_secondscame only from the Smart Irrigation soil deficit, so it could go true only on a Home Assistant install carryingsensor.smart_irrigation_<slug>; a standalone install read the absent entity as a0.0deficit and the field was alwaysfalse.
1.24.0 (a zone binds to a controller zone by id). Additive only; no existing response shape changes. ZoneConfig gains controller_zone_name in GET/PUT /api/config and the config schema: the controller’s own name for the bound zone (null when the binding was typed by hand or predates this release). It is a display label. Nothing dispatches on it, nothing keys on it, and a stale value cannot mis-actuate. controller_station keeps its shape and its meaning and additionally becomes #[serde(default)], so a hand-written config that omits it parses instead of failing.
The behavior change is in what fills controller_station. It is now the binding a user picks, and a controller’s own zone_*_map is the fallback that keeps a pre-existing config watering unchanged. On load, LocalSky copies a map entry into any zone of that controller whose controller_station is empty and whose slug the map covers, so an install bound only through the map ends up with the same binding visible on the zone. It never overwrites a non-empty station and never rewrites or removes the map. It is idempotent, and the next config write persists the copied value.
ha_service_call now reads controller_station and overlays it onto zone_entity_map, which it never did before: an entity id in that field used to be silently ignored. A value that is not entity-id shaped (domain.object_id) is warn-skipped rather than sent to Home Assistant, so the legacy v0.1 station numbers stay inert. mqtt_command is explicitly exempt (its per-zone value is a command struct a string cannot carry) and esphome_native still builds nothing.
New validation warning zone_unbound: a zone whose controller exists, whose station is empty, and whose controller’s zone map has no entry under its slug. A warning, never an error, so no previously loadable config becomes unloadable. It is honest per kind rather than uniform: mqtt_command ignores the station field entirely, so only its zone_command_map counts; dry_run accepts any slug and is never unbound; and esphome_native reports the separate zone_controller_not_built warning, because its adapter is not constructed and neither binding fires.
Its companion zone_station_unparseable covers the case that looks bound and is not: controller_station holds a value the controller kind cannot use (a Rachio UUID on a Hydrawise zone, a station number on a Rachio zone, an OpenSprinkler 0, a bare number where Home Assistant needs an entity id), and no zone map entry covers the zone either. Dispatch already ignored such a value; now the config check names it, says what that kind expects, and stops counting the zone as bound. The check calls the same per-kind parsers build_controllers binds with, so it cannot disagree with dispatch. A zone whose controller map still covers it keeps watering and is not reported.
BOTH whole-config write paths, PUT /api/config and PUT /api/config/raw, now refuse a save that drops a zone key and adds another in the same write with 422 zone_key_renamed, because a zone’s slug keys its history, its overrides, its in-flight run ledger, its tuning dismissals, its soil channel, its Home Assistant entity ids and its retained MQTT discovery topics. When the removal and the addition really are unrelated zones, either save them as two separate writes or repeat the one write with ?allow_zone_key_change=1 (=true, =yes, =on, and a bare ?allow_zone_key_change are accepted too). The unknown-zone 400 body’s hint no longer suggests renaming a zone to match a map key; it points at the zone’s Controller station field and says why renaming is not the fix.
An operator upgrading a Home Assistant install should check the boot log once. A zone that carries BOTH a station value and a zone_entity_map entry now dispatches to the station value, and the log names every zone whose target moved.
No HACS integration change is required. The integration reads /api/v1/info, the sensor manifest, the streams, the action endpoint and the session endpoint, and gates on the API major only. It never reads /api/config, so an additive ZoneConfig field is invisible to it.
1.23.0 (controller failures become distinguishable). POST /api/v1/irrigation/action used to answer 502 Bad Gateway for every controller failure except an unknown zone (400) and an unsupported operation (501), so a rejected credential, an exhausted daily request budget, a vendor rejecting the zone id, and a transport failure were one indistinguishable status. The mapping is now 424 Failed Dependency for a rejected controller credential, 429 for a rate-limited controller, 400 for an unknown zone, 501 for an unsupported operation, and 502 for the failures that really are upstream or transport (the controller returned an error, the request never completed, the controller is offline, or the adapter failed to initialize). Not 401: on every LocalSky endpoint 401 means this deploy’s authentication failed, and clients act on it accordingly (the HACS integration starts a reauthentication flow), so a revoked vendor key must never borrow that status. Branch on code, not on the status. Every error body now carries a stable code: zone_unknown, controller_auth_failed, controller_rate_limited, controller_unsupported, controller_unreachable. A client that treats any non-2xx as a failure needs no change; one that pinned 502 as the controller-failure status must widen to the set above. No HACS integration change is required. Additive alongside it: the 429 body carries rate_limit_remaining (what the controller’s last response reported, as a string, null when it reported none, never a zero standing in for unknown); the 400 body carries mapped_zones, the controller’s zone-map keys, so a zone-slug mismatch is diagnosable from the error alone; the 400, 424, and 429 bodies carry a hint naming the fix; the unknown-zone error text now reads zone "<slug>" is not mapped to a zone on this controller instead of zone unknown: <slug> (it was never a parseable code); and a successful run, stop, or stop_all response gains confirm_within_s, how many seconds that controller can take to report the change (null when it reads state on demand), so a client can say a change was accepted and confirmation is still pending rather than implying a dispatch failed when its own confirmation window is shorter than the controller’s poll interval.
1.22.0 (Rachio first-class). Additive only; no existing response shape changes. RachioConfig gains poll_interval_s (seconds between live status polls against the Rachio cloud, 60..=3600, null = the 120s default; values outside the band fail validation) and base_url (null = the production endpoint) in GET/PUT /api/config. POST /api/v1/wizard/test_controller for a rachio entry adds discovered_device ({ device_id, name, device_count } when the posted entry carried an API token but no device id, so the form can offer the resolved id; null otherwise) and rate_limit_remaining (the cloud’s reported remaining daily request budget, null when the header was absent). POST /api/v1/wizard/scan_zones and test_controller now restore redacted-secret sentinels from the stored config by entry id (the PUT /api/config pattern) and answer 400 unmatched_redacted_secret when no stored value matches, so probing an existing cloud controller works without retyping its secret. When a stored secret is restored, the probe’s transport fields (base_url, host, ports) are pinned to the stored entry’s values; a probe that changes them alongside a redacted secret answers 400 transport_field_mismatch (save the address change first, or re-enter the secret). The manual stop action’s response gains scope ("zone" or "device") plus a note when the controller has no per-zone stop and the whole device was stopped. The new ControllerCaps.per_zone_stop bit is internal (capability structs never ride the wire).
1.21.0 (weekly water balance). Additive only; no existing response shape changes. Each water_budgets[] row in the irrigation snapshot gains the settled balance terms: observed_rain_mm + observed_rain_source (gauge | radar | model_archive | none), applied_mm (gross irrigation over the trailing 7 days, union-clustered watering evidence), forecast_credit_mm + forecast_credit_source (bias_forecast | none), bias_multiplier + bias_sample_count (the current month’s forecast-bias correction; 1.0 with the sample count when under-trained), and remaining_sessions. The existing fields keep their meaning: today_seconds is still the actual seconds to water today (the external HA automation contract), expected_rain_mm keeps its historical wire scaling (probability-weighted 7-day forward forecast in mm times the 0.7 capture factor; informational, the balance itself subtracts only the bias-corrected credit up to the next session), needed_mm is now the balance remainder, and mm_per_session is the per-remaining-session gross depth. Session sizing no longer multiplies by the heat multiplier or divides by capture efficiency. GET /api/v1/irrigation/history run rows gain source and status. The tuning report’s zones[] gain dismissed + dismissed_fields, and two privileged endpoints manage silencing: POST /api/v1/irrigation/tuning/dismiss {zone_slug, field, recommendation_id, kind: "snooze" | "permanent"} (a snooze keys the exact recommendation id and expires after 30 days; a permanent dismissal keys the zone + field and survives value drift) and POST /api/v1/irrigation/tuning/undismiss {zone_slug, field}. A dismissed or snoozed suggestion is stripped inside the report’s ranked pick server-side (the zone’s next-ranked suggestion, if any, surfaces instead), so counts and the weekly push go quiet with it. The open_meteo source’s past_days config is now honored by the fetch (clamped 1..=7; default 3), and the forecast_observations ledger records each day’s observed rain as a day-max with an observed_source tag.
1.20.0 (per-zone run limit). Additive only. ZoneConfig gains max_run_minutes (whole minutes, 5..=360, null = the 60 minute default) in GET/PUT /api/config and the config schema; the value hot-reloads on save (no restart). The tuning report can now recommend max_run_minutes (its suggested_value is in minutes), and POST /api/v1/config/zones/apply accepts the field alongside the existing set (soil_texture, precip_rate_mm_hr plus precip_rate_source, root_depth_mm, mad_pct_override, weekly_budget_in, sessions_per_week); out-of-band values answer 422. A config write that raises a zone’s limit past 60 minutes emits a Web Push notice (tag cap-raised-<slug>, deep link /zones/<slug>) to subscribed devices after the save. No existing response shape changes.
1.19.0 (tuning report). Additive only; no existing response shape changes. GET /api/v1/irrigation/tuning?days=N (clamp 7..=30, default 14) returns the per-zone results-based tuning report: { generated_epoch, window_days, zones: [ { slug, display_name, status, lines, recommendation } ], scorecard }, at most one recommendation per zone, each carrying the target config field, current and suggested values as JSON (null clears an override), companion fields the apply writes alongside (a measured precipitation rate also stamps precip_rate_source), the plain-language headline, the evidence lines, and a stable id. The scorecard’s scored_days / confirmed_days cover forecast rain skips and are null until at least 3 such days could be judged; reactive rain skips (rain already falling or on the ground) ride the additive reactive_days / reactive_line as a plain count (the 1.18.0 honest-unknowns register; never a zero sentinel). POST /api/v1/config/zones/apply writes one recommendation through the validated config path; it is privileged like every config write, regenerates the recommendation server-side, and answers 409 when the supplied id no longer derives from current data. Like the other history reads, /irrigation/tuning mounts only when the history database is available. No HACS integration change is required.
1.18.0 (honest unknowns). Several fields whose zero doubled as “no data” are now nullable, and a handful of manifest entities are capability-gated. Nullable (each still always present; null is the documented unknown value, and a client that treated the old 0 as a real reading was already reading a defect): tempest.pop_pct and tempest.leaf_wetness_pct (null until a configured source writes them), irrigation.water_level_pct (null when the controller does not report a level; previously a fabricated 100 on native installs and 0 on HA installs with no entity), forecast.eto_today_mm (null when no source/forecast/native compute produced one; the flat 5.0 fallback no longer publishes), forecast.temp_max_today_f / temp_min_today_f / humidity_mean_today_pct (now resolved from the live forecast first, legacy HA sensors second, null when neither exists), and the precipitation probabilities (skip_check.rain_tomorrow_prob_pct, forecast.rain_tomorrow_prob_pct, seven_day_verdicts[].precip_probability_max), which are null when the forecast provider reports no probability series; the probability-weighted rollups now take probability-less rain at full value instead of zeroing it. Additive alongside these: irrigation.water_level_capable (whether the active controller reports a water level). Manifest schema is 1.4: pop_pct, wet_bulb_f, wind_lull_mph, rain_in_last_min, illuminance_lx, water_level_pct, and the per-zone soil moisture/temperature/EC/battery descriptors now publish only when the install actually has the backing source, station, controller capability, or soil probe, so installs without the hardware stop growing dead entities. The HACS integration already renders null as unavailable; no integration change is required.
1.17.0 (lightning). tempest.lightning_avg_dist_mi is now nullable: it is null whenever the reporting interval detected no strikes, where it previously carried the station’s bare 0. On a distance channel that 0 read as a strike directly overhead, so a client filtering on distance < 10 saw a phantom storm between strikes, and the obvious guard distance > 0 dropped real readings. The field is still always present, and null is the documented unknown value. If you want a distance that persists between strikes, read the new last_strike_distance_mi (also exposed as a sensor descriptor in the manifest) instead of the interval average. Separately, lightning_strikes_last_hour now decays as strikes age out of the hour rather than holding the last storm’s total until the next strike; a trigger of the form “strikes above 0” re-arms on its own once it reaches 0.