Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LocalSky

These docs track LocalSky v0.9.0.

Hyperlocal weather on your hardware. Smart irrigation when you want it.

LocalSky is two products in one Docker container.

A self-hosted weather dashboard that is cloud-first out of the box: a new install with no hardware picks up Open-Meteo automatically and shows you weather immediately, then reads your weather station over the LAN when you add one (Tempest, Ecowitt, Ambient Weather, Davis, and more). It merges Open-Meteo with regional forecast sources (NWS in the US, MET Norway, OpenWeather, Pirate Weather) using per-field priority chains, so you set the backup order for each reading yourself, and it labels every reading with an honesty tag (measured, radar, nowcast, or forecast) so you always know where a number came from. Display units are configurable, with a household default and a per-device override. The result renders in a fast installable PWA with built-in radar (RainViewer worldwide, NOAA MRMS and IEM NEXRAD in the US) and lightning. Useful on its own, even if you never irrigate anything.

A smart irrigation engine that pairs the same weather data with peer-reviewed agronomy (FAO-56 reference ET, USDA soil textures, species-aware Kc curves, a 24-rule skip ladder) and drives OpenSprinkler, Rachio, Rain Bird, Hydrawise, B-hyve, or any valve reachable over MQTT or Home Assistant. Optional. Off until you wire a controller.

This site is the operator’s manual. The dashboard, settings UI, and first-run wizard are designed to keep you out of YAML and out of the terminal for day-to-day use. The chapters here exist for when you want to understand exactly what the engine is doing, swap a sensor source, calibrate a zone, or wire LocalSky into the rest of your stack.

Where to start

  • New install: jump to Quick start for the docker run and the first-run wizard walkthrough.
  • Weather-only user: the wizard’s “Controllers” step can be skipped. The irrigation surfaces disappear and LocalSky runs as a pure weather product.
  • No Home Assistant: Standalone mode covers sensors via MQTT, Ecowitt LAN, and HTTP webhooks.
  • Existing HA user: Home Assistant integration covers the LocalSky integration for HA (installed through HACS). It discovers LocalSky on your network and brings live weather, every zone and its valve, forecasts, and run/stop/pause controls into HA as native entities and services.

Where things live

What you want to knowChapter
What weather sources LocalSky can readWeather and soil sensors
How the engine decides whether to waterIrrigation engine + Skip rules in depth
Which grass species the catalog supportsGrass species catalog
Which soil textures the catalog supportsSoil texture catalog
Which controllers LocalSky drivesIrrigation controllers
Every config optionConfiguration reference
Every REST + SSE endpointREST + SSE API
Upgrade from v0.1Upgrading LocalSky
Something brokeTroubleshooting
Quick answersFAQ

Two ways to run it

LocalSky is designed to work well in either configuration:

  • Standalone: a self-contained service that talks directly to your weather sensors (and optionally to your irrigation controller). Add sensors over MQTT, Ecowitt LAN POST, or HTTP webhooks.
  • Alongside Home Assistant: install the LocalSky integration from HACS and HA finds LocalSky on your network by itself. HA gets native entities and controls (live weather, zones, valves, forecasts, run/stop/pause); LocalSky owns irrigation scheduling and actuation. An MQTT discovery publisher is also available for setups that prefer MQTT.

Both modes are first-class. Pick the one that fits your stack.

Everything runs on your own hardware, and every script and stylesheet the pages use is served by LocalSky itself; nothing is fetched from a CDN. The outbound calls an install makes are these, and each is yours to make or not:

  • Forecast and observation providers you configure: Open-Meteo, NWS, Met.no and the other sources under Devices. A location with no sources fetches nothing.
  • Radar and map tiles when you open the radar page: the basemap comes from CARTO and the precipitation layers from the radar provider you pick (RainViewer by default). A LAN-only install can leave the radar page closed, or disable the radar providers, and makes none of these.
  • Cloud-backed controllers you connect (Rachio, B-hyve, Hydrawise) and notification sinks you enable (ntfy, Slack, Web Push).

No telemetry, no analytics, no update check unless you turn it on. A LAN-only setup with a local controller and no radar makes no outbound call at all.

Getting Started with LocalSky

This guide takes you from “no LocalSky installed” to “watching real weather and managing real zones” in about 15 minutes. Two paths: Demo mode if you just want to see the UI, and Real install if you have hardware.

Docker is the preferred way to run LocalSky, and it is what this guide covers. That said, several platforms are supported, and if you run Home Assistant OS there is a convenience option: LocalSky also installs as a Home Assistant app in one click, wizard and all, using the exact same image. (Home Assistant OS and Supervised installs only; the app store does not exist on HA Container/Core, so those use the Docker install below.)

Add the LocalSky app repository to my Home Assistant

Prerequisites

LocalSky is delivered as a Docker image. Anywhere Docker runs, LocalSky runs:

  • Linux (any distro; native Docker)
  • macOS (Docker Desktop, OrbStack, or colima)
  • Windows (Docker Desktop with WSL2 backend)
  • Synology / QNAP NAS (Container Manager)
  • Raspberry Pi 4 or 5 (64-bit OS, multi-arch image ships arm64)
  • Unraid, Proxmox, TrueNAS Scale

You do not need a Linux box, a server room, or a dedicated machine. A workstation that’s powered on most of the day works fine; LocalSky runs in ~30 MB resident memory.

What you do need:

  • About 200 MB of disk for the image + a few hundred KB for the SQLite database
  • A free port (8090 by default; remap at the docker run layer if taken)
  • (Optional) An always-on host if you want irrigation to dispatch on schedule

Demo mode (no hardware required)

docker run -d \
  --name localsky \
  -p 8090:8090 \
  -e LOCALSKY_DEMO=1 \
  ghcr.io/silenthooligan/localsky:latest

Open http://localhost:8090. The dashboard renders with simulated weather and an in-memory dry-run controller. Every actionable button shows what it would have done but never fires anything. Useful for:

  • Exploring the UI before committing to a hardware setup
  • Showcasing LocalSky to friends or in a presentation
  • Running screenshots for documentation
  • Verifying a Docker image build before deploying it

The demo data loops on a synthetic humid-subtropical summer day at 10× wall-clock rate. No external network calls except the Leaflet stylesheet for the radar map.

When you’re ready for the real thing, remove the demo container with docker rm -f localsky and follow the install below. The demo container was started without a volume mount, so nothing it generated persists on disk.

Real install

What you need

  • Docker (see Prerequisites above)
  • Your latitude and longitude
  • (Optional) An irrigation controller. See docs/controllers.md for the supported list. Without one, LocalSky becomes a hyperlocal weather dashboard with no actionable irrigation; that’s a fine starting point.
  • (Optional) An LLM endpoint for the advisor. Ollama on the same host is the easiest path; see docs/llm.md.

Install

docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -p 50222:50222/udp \
  -v localsky-data:/data \
  ghcr.io/silenthooligan/localsky:latest

localsky-data is a named Docker volume that holds the config file (/data/localsky.toml) and the SQLite database. Docker creates it on first run and it survives container upgrades.

Prefer a bind mount? The container runs as the non-root user uid 10001 and fixes ownership of the mounted /data itself on startup, so a host directory (-v /opt/localsky/data:/data) works with no manual chown. The only requirement is that /data is writable, so do not mount it read-only.

Networking for LAN weather stations. On Linux, --network host is recommended: WeatherFlow Tempest hubs broadcast on UDP port 50222, and the wizard’s network discovery (Tempest and Ecowitt broadcasts, OpenSprinkler subnet sweep) needs to see your LAN. With host networking, drop the -p flags; LocalSky listens on port 8090 directly. The bridged alternative shown above (-p 8090:8090 -p 50222:50222/udp) works too, but LAN broadcasts may not cross the bridge, so discovery can miss devices.

Docker Compose

The same install as a docker-compose.yml:

services:
  localsky:
    image: ghcr.io/silenthooligan/localsky:latest
    container_name: localsky
    restart: unless-stopped
    # Recommended on Linux so Tempest UDP broadcasts and network
    # discovery reach the container. Remove the ports: block if you
    # uncomment this.
    # network_mode: host
    ports:
      - "8090:8090"
      - "50222:50222/udp"
    environment:
      - TZ=America/New_York  # your IANA timezone, e.g. Europe/Berlin, Australia/Sydney
    volumes:
      - localsky-data:/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8090/api/v1/health"]
      interval: 30s
      timeout: 5s
      start_period: 30s
      retries: 3

volumes:
  localsky-data:

Once the container is up, open http://localhost:8090 and you land in the first-run wizard: every page of an unconfigured install goes to /setup until the wizard saves a config. Until a location is saved, LocalSky fetches no forecast at all (there is no default city).

First-run wizard

Nine steps; none take more than a minute. Three of them (AI advisor, Notifications, Account) are optional, and the progress strip renders them as hollow dots.

  1. Welcome: what LocalSky is and the Apache-2.0 license acknowledgement. No telemetry, no analytics, no email signup.
  2. Your location: search for your address (built-in geocoding) or enter latitude and longitude directly. Elevation auto-fills from your location (still editable) and improves the FAO-56 ET₀ math; the timezone autofills from an offline dataset whenever lat/lon change.
  3. Weather: add weather and sensor sources with the same editor used in the Devices hub. A one-click network scan finds Tempest and Ecowitt hardware on your LAN. Skipping is fine: LocalSky uses free Open-Meteo automatically for cloud weather and forecasts, so you see weather immediately even without adding a source. Sources can be added or changed any time under Settings > Devices.
  4. Controller: add your irrigation controller with the same editor as Settings, test it live against the real hardware, and scan it for zones. Scanned zones can be imported as zone stubs, each already carrying the controller’s own id for it. Later, in Settings, a scan on an existing controller instead offers a table where you say which of your zones each of the controller’s zones fires.
  5. Zones: explains LocalSky’s zone model, asks what feeds your sprinklers (municipal or pressurized, a well or low-recovery supply, or not sure; a well answer keeps cycle-and-soak pauses idle so the supply can recover between runs), and shows the grass-species gallery so you pick the right species. Zone editing itself lives under /settings/zones after the wizard; zones imported from a controller scan arrive there pre-populated and already bound. A zone added by hand picks its controller zone from a list where the controller can be asked, so the two sides never have to be named the same.
  6. AI advisor (optional): pick an LLM provider, or None. You can test the connection live before finishing. See llm.md.
  7. Notifications (optional): Web Push, MQTT, ntfy, Slack. All independent; none required.
  8. Account (optional): create the owner account (username plus a password stored as an argon2id hash). The account is created immediately and you are signed in on that browser; finishing setup switches authentication to required. Skipping leaves auth disabled. See authentication.md.
  9. Review & apply: a per-section summary with edit links back into each step. Save and finish writes the config and sends you to the dashboard.

After the wizard

Every step is optional and configurable later under Settings: Devices (the unified hub for weather sources and controllers, per-reading priority and backup chains, and the forecast source picker), Units (display units for temperature, rainfall, wind, pressure, distance, and zone area, a household default that any device can override), and Zones (add or edit zones, link soil sensors, choose a controller).

Everything is editable under /settings. See docs/configuration.md for the field-by-field reference.

Standalone vs Home Assistant integration

TL;DR: LocalSky is a complete native product, not an HA add-on. Smart Irrigation and Irrigation Unlimited are no longer required; LocalSky’s engine does what they did. HA can still play a role (paths 2 to 4 below) but is never a dependency. Deep version: docs/standalone.md.

LocalSky has four integration paths. Pick the one that fits your stack.

Path 1: Standalone (the default)

LocalSky talks directly to your irrigation hardware. No HA install required, no MQTT broker.

Setup:

  1. Run the install command above.
  2. In the wizard’s Controller step, add your direct-controlled controller (OpenSprinkler is the canonical example) and test it.
  3. Done. LocalSky’s dashboard becomes your irrigation surface; the engine drives zones directly.

What this gets you:

  • Weather dashboard
  • Engine-driven irrigation with full ET / soil / skip-rule logic
  • Controller HAL handles dispatch
  • Push notifications via Web Push (browser only)
  • Optional LLM advisor

What you give up: HA’s broader sensor + automation ecosystem. If you don’t have HA today, you don’t need it.

Install the LocalSky integration through HACS. It polls LocalSky’s REST API and creates native HA entities, driven by LocalSky’s entity manifest (/api/v1/sensors/manifest), so new zones and sources show up in HA automatically with no MQTT broker and no YAML. LocalSky still owns the irrigation engine and talks to the controller itself; HA gets a live, read-and-act view.

Full walkthrough: docs/hacs.md.

Path 3: MQTT discovery (when a broker already runs)

LocalSky talks to your controller directly, AND publishes its state via MQTT discovery so HA dashboards see sensor.localsky_* entities automatically. An alternative to the HACS integration when you already run a broker; do not enable both, or you get duplicate entities.

Setup:

  1. Same install command; add your controller under Settings > Devices.
  2. Under Settings > Notifications, set the MQTT broker host, port, credentials, and discovery prefix, and leave publishing enabled.
  3. Settings > Home Assistant shows whether discovery is currently publishing.
  4. HA auto-discovers the entities once its MQTT integration is connected to the same broker.

Path 4: HA service-call controller (valves only HA can reach)

LocalSky’s controller dispatches through HA service calls instead of directly. Useful when you already run an HA-driven irrigation integration (opensprinkler HACS, irrigation_unlimited, and similar) and don’t want to re-plumb, or when only HA can reach the valves.

Setup:

  1. In the wizard’s Controller step (or Settings > Devices), pick the ha_service_call controller type.
  2. Give it your HA base URL and a long-lived access token, and map your LocalSky zone slugs to HA entity ids. The start and stop services are configurable (defaults target an OpenSprinkler-style setup).
  3. LocalSky dispatches runs via HA’s /api/services/<domain>/<service> API.

This is the path for upgrading an existing HA-driven irrigation setup without losing automations.

Remote reachability

LocalSky listens on 0.0.0.0:8090 inside the container by default. Several ways to reach it from outside the LAN:

Tailscale (easiest)

Install Tailscale on the host running Docker. Connect your devices to the same tailnet. Visit http://<host-tailscale-ip>:8090 from anywhere. No port forwarding, no DNS, no TLS cert; the tailnet does WireGuard between your devices and authenticates via your identity provider.

# On the Docker host
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

The dashboard works through Tailscale exactly as on localhost.

Reverse proxy with TLS (production)

Front LocalSky with Caddy, nginx, or Traefik. Get a free Let’s Encrypt cert. Expose the proxy port (443) to the internet.

Caddy example:

localsky.example.com {
    reverse_proxy localhost:8090
}

LocalSky ships built-in authentication: an owner account (password stored as an argon2id hash) plus API tokens for integrations. New installs that create the owner account in the wizard’s Account step finish with auth mode set to required; installs that skip that step default to mode = "disabled" in the [auth] config section. Proxy-level auth (basic auth, oauth2-proxy) is optional defense in depth on top of that, not a substitute. Details: docs/authentication.md.

Cloudflare Tunnel

cloudflared tunnel exposes LocalSky via a Cloudflare-managed edge without opening any ports. Works behind CGNAT and on networks that don’t allow inbound connections.

docker run -d \
  --name cloudflared \
  --restart unless-stopped \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run --token YOUR_TUNNEL_TOKEN

Local LAN only

Without any of the above, the dashboard is reachable from any device on the same LAN at http://<host-lan-ip>:8090. Add an mDNS / Avahi entry for nicer URLs (http://localsky.local:8090).

Mobile PWA from a remote URL

The Web Push functionality works through any of the reachability options above. Subscribe per device once the dashboard is loaded. The service worker handles offline reads of cached snapshots so the dashboard stays usable when the device is off-network.

Irrigation controllers

The full list of supported controllers and their integration shape lives in docs/controllers.md. Short version:

  • OpenSprinkler (firmware 2.1.9+), the ideal controller. Direct HTTP API on the LAN, no cloud, US$130-180 hardware (US pricing; varies by region).
  • OpenSprinkler Pi: same protocol as the boxed version; runs on a Raspberry Pi
  • Home Assistant service call: works with any HA-driven irrigation integration (opensprinkler HACS, irrigation_unlimited, rachio, esphome sprinkler component, hubitat sprinkler, etc.)
  • DIY / ESP32 controllers: an ESP32 + relay board driven over MQTT or a small HTTP contract (the native ESPHome protobuf adapter is still scaffolded, not built; use MQTT or HTTP for ESPHome hardware). US$5-40 in parts
  • Cloud controllers: Rachio Gen 2/3, Hunter Hydrawise, Orbit B-hyve, and Rain Bird, each driven natively through its vendor cloud with your account. US$80-300 hardware
  • DryRun: no-op for testing + demos

LocalSky’s controller HAL is a Rust trait; adding new adapters takes ~100-200 lines. See CONTRIBUTING.md.

Optional: sensors

LocalSky’s engine is fully functional without any sensors beyond the weather sources. Adding sensors unlocks additional logic:

Sensor typeUnlocks
Soil moisture (Ecowitt WH51 / WH52, Aqara, Sonoff)Per-zone saturation skip, soil-moisture projection, smarter dry-out detection
Soil temperatureSoil-frost skip rule (catches the “cold soil + sprinkler = frozen lawn” case better than air temp alone)
Rain gauge (separate from weather station)Improves rain-today accumulation accuracy
Lightning detectorPowers the lightning panel + safety skip during active storms
Flow meter (on controller)Validates actual delivered water vs. computed mm depth

The dashboard renders cleanly without any of these; sensor tiles show empty states with “Connect a sensor to unlock soil-saturation rules” affordances. Once a source provides the data, the tile lights up and additional skip rules activate. The engine never blocks on missing sensor data, weather + ET-based math is the always-on baseline.

Optional: Local LLM

LocalSky’s advisor produces plain-English explanations of why today’s verdict is what it is. It is entirely optional: point it at any OpenAI-compatible endpoint, a local Ollama or llama.cpp instance, or nothing at all. Setup, provider options, and model recommendations live in docs/llm.md.

Troubleshooting

  • Dashboard says “no zones”: the wizard hasn’t been run, or the zone editor was skipped. Visit /setup or /settings/zones.
  • Verdict shows “(weather rules only; soil rules offline)”: a soil moisture probe isn’t reporting. Check the source under Settings, then Devices (/settings?section=devices).
  • LLM advisor is grayed out: provider is unreachable. Check it under Settings, then AI advisor (/settings/llm).
  • MQTT discovery isn’t creating entities in HA: HA’s MQTT integration needs the broker connected (Settings → Devices & Services → MQTT → Configure). Discovery topics live under homeassistant/<component>/<your-deployment-slug>/....
  • Container won’t start on Raspberry Pi: confirm 64-bit OS (uname -m should report aarch64). 32-bit Pi OS is not supported.

Next steps

Standalone Mode (No Home Assistant)

LocalSky is a complete, native irrigation + weather product. Home Assistant is one of several integration paths, not a dependency. This document is for users who:

  • Don’t run Home Assistant and don’t want to
  • Run HA but want LocalSky to own irrigation end-to-end
  • Need to understand exactly what works without HA

What “standalone” gets you (the short answer)

Everything. The full LocalSky feature set runs without HA:

  • Live weather dashboard (Tempest UDP / Open-Meteo / Ecowitt / NWS / etc.)
  • FAO-56 reference ET₀ with Hargreaves fallback
  • Per-zone water balance + MAD-driven scheduling
  • 24-rule skip ladder
  • 7-day forward verdict strip
  • Cycle-and-soak runtime splitting
  • Direct controller dispatch (OpenSprinkler HTTP API, Rachio, Hydrawise, B-hyve, Rain Bird, MQTT command)
  • Sensor ingestion via MQTT subscribe + direct LAN adapters
  • LLM advisor (Ollama, llama.cpp, or any OpenAI-compatible)
  • Web Push notifications (browser, per device)
  • PWA install on iOS + Android
  • Settings UI + first-run wizard

What you don’t get without HA:

  • HA’s broader home-automation ecosystem (lights, locks, scenes)
  • HA’s dashboard widgets and other integrations

That’s a fair trade if you don’t already run HA.

Sensor ingestion without Home Assistant

Cloud weather sources are first-class without Home Assistant: an install with no hardware uses Open-Meteo (free, no API key) automatically, so you see weather immediately. Add more sources (Tempest, Ecowitt, NWS, NOAA MRMS radar rain, Synoptic Data, and others) and set per-reading priority chains so each source provides its strongest readings.

This is the question that surfaces most often: “I have soil moisture sensors. How do they get into LocalSky without HA?” Three paths, none requiring HA.

Path 1: MQTT broker (the universal path)

Most modern sensors publish to MQTT. LocalSky’s mqtt source subscribes to topics directly. The architecture:

[Sensor: Tasmota / ESPHome / Zigbee2MQTT / etc.]
              |
              v (publishes to topic)
        [MQTT broker: Mosquitto]
              |
              v (LocalSky subscribes)
        [LocalSky source: kind = "mqtt"]

The broker can be Mosquitto (open-source, free, runs in a 5 MB Docker container), EMQX, HiveMQ, or anything that speaks MQTT 3.1.1 or 5.0. HA’s broker works too if you already have one; the point is the broker is the standard, not HA.

Set up Mosquitto

mkdir -p /opt/mosquitto/{config,data,log}
cat > /opt/mosquitto/config/mosquitto.conf <<'EOF'
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
EOF

docker run -d \
  --name mosquitto \
  --restart unless-stopped \
  -p 1883:1883 \
  -v /opt/mosquitto/config:/mosquitto/config \
  -v /opt/mosquitto/data:/mosquitto/data \
  -v /opt/mosquitto/log:/mosquitto/log \
  eclipse-mosquitto:latest

Lock this down with username/password before exposing to anything but localhost.

Configure LocalSky to subscribe

In /data/localsky.toml (or under Settings, then Devices):

[[sources]]
id = "mqtt_sensors"
priority = 80
enabled = true
kind = "mqtt"
[sources.config]
broker_host = "192.0.2.5"     # the mosquitto host
broker_port = 1883
username = "${MQTT_USER}"
password = "${MQTT_PASSWORD}"

[[sources.config.subscriptions]]
topic = "tasmota/soil/back_yard/SENSOR"
field = "soil_moisture_pct"   # planned WeatherField variant for per-zone soil
json_path = "ANALOG.A0"
zone_slug = "back_yard"
scale = 0.0976                # adjust for sensor calibration
offset = 0.0

[[sources.config.subscriptions]]
topic = "esphome/lawn/temperature/state"
field = "air_temp_f"
# no json_path means parse whole payload as a number
# (ESPHome native API publishes raw values to /state topics)

The adapter handles:

  • MQTT 3.1.1 + 5.0
  • Wildcards: + for one segment, # for trailing segments. Example: tasmota/+/SENSOR matches every Tasmota device’s SENSOR topic
  • Plain numeric payloads (Tasmota / ESPHome /state topics)
  • JSON payloads with arbitrary nesting and arrays via json_path. Examples:
    • "soil.moisture" reads obj["soil"]["moisture"]
    • "sensors.0.value" reads obj["sensors"][0]["value"]
  • Tasmota-style number-as-string payloads
  • Linear transforms: published_value * scale + offset for unit conversion or sensor calibration

Hardware that works this way

DeviceHow it gets to MQTTLocalSky path
ESPHome-flashed ESP32 + sensorNative MQTT publish (or via HA’s MQTT integration)Subscribe to esphome/<device>/<sensor>/state
Tasmota-flashed deviceNative MQTT publishSubscribe to tasmota/<device>/SENSOR
Zigbee sensors (Aqara, Sonoff)Via Zigbee2MQTT (no HA needed)Subscribe to zigbee2mqtt/<friendly_name>. Already on ZHA or Z2M feeding HA? Use the HA passthrough source (kind = ha_passthrough) instead; no re-pairing.
Ecowitt gateway (WH51, WH52)Via ecowitt2mqtt sidecarSubscribe to ecowitt/<device_id>
Shelly devicesNative MQTT (firmware setting)Subscribe to shellies/<device>/<field>
Arbitrary Arduino / Pi projectPubSubClient / paho-mqttSubscribe to whatever topic you publish

Zigbee2MQTT is a particularly good fit. It’s a single Docker container that talks to a USB Zigbee coordinator (Conbee II, Sonoff dongle, etc.) and publishes every Zigbee device’s state to MQTT. No HA required.

Path 2: Direct LAN adapters

For sensors that speak a documented LAN protocol, LocalSky can talk to them directly without MQTT in the middle.

SensorAdapterStatus
Tempest hub (UDP broadcast 50222)tempest_udpShipped
Ecowitt GW1100 / GW2000 (LAN push to /ingest/ecowitt)ecowitt_localShipped
Ecowitt GW1100 / GW2000 (native LAN poll, incl. per-channel soil calibration)ecowitt_gw_pollShipped
Ambient Weather (cloud REST)ambient_weatherShipped
ESPHome native API (protobuf over TCP)esphome_native (sensor mode)Planned

Direct adapters bypass MQTT entirely; the device talks to LocalSky’s listener directly. Less infra, no broker. Use when the device supports a documented protocol that LocalSky has an adapter for.

Networking note: Tempest UDP

The Tempest hub broadcasts on UDP port 50222, and broadcasts do not cross Docker’s default bridge network. Run the LocalSky container with network_mode: host (the repo’s docker-compose.yml already does) so the listener actually hears the hub. On a multi-homed host this also lets one NIC face the sensor subnet while another handles outbound API calls.

Path 3: HTTP webhook receiver

For sensors with arbitrary HTTP push capability (some commercial weather stations, custom scripts), the generic http_webhook source accepts JSON POSTs directly:

[[sources]]
id = "lawn"
kind = "http_webhook"
[sources.config]
path = "/ingest/lawn"
token = "${WEBHOOK_TOKEN}"     # optional; sent via X-LocalSky-Token header or ?token=

[[sources.config.fields]]
field = "air_temp_f"
json_path = "outdoor.temp"     # drill into the JSON payload
scale = 1.0
offset = 0.0

The device POSTs JSON to http://localsky:8090/ingest/webhook/lawn (the URL segment is the source id).

Field names use the same snake_case weather-field vocabulary as the MQTT source, and the same json_path + scale/offset transform scheme.

Controller dispatch without Home Assistant

You have several direct paths:

Direct HTTP API on the LAN. See docs/controllers.md. US$130-180 hardware; the engine talks to it without anything else in the middle.

ESP32 / DIY (open hardware)

For full open hardware: an ESP32 + relay board, ~US$15-40 in parts. LocalSky drives it two ways, no HA needed: the http_generic controller (LocalSky polls a small REST contract; a copy-and-flash Arduino sketch ships in examples/http/), or the mqtt_command controller for boards that speak MQTT (ESPHome/Tasmota; reference ESPHome firmware in examples/esphome/). See DIY & ESP32 controllers. A native ESPHome protobuf adapter is scaffolded but not yet built.

Rachio, Hydrawise, B-hyve, Rain Bird

Vendor cloud or LAN APIs, no HA required. LocalSky ships direct adapters for all four; see docs/controllers.md for setup per vendor.

Existing setups: what if I already have HA driving Rachio / Hunter / B-hyve?

Use the ha_service_call controller. LocalSky dispatches through your existing HA setup. This is the “legacy continuity” mode; you keep HA in the loop because the integration to your hardware already lives there.

Weather and controller management

Everything lives in Settings > Devices: per-reading priority chains (drag to order which source owns each reading; the first reporting wins, the next takes over if it goes quiet), the forecast source picker, live source and controller status, and edit or remove without hand-editing config. Every reading shows an honesty label (measured, radar, real-time nowcast, or model forecast).

Reaching LocalSky remotely without HA

HA is sometimes used as a remote-access shim. If you’re not running HA, options:

  • Tailscale – recommended; works on any platform
  • Reverse proxy + TLS – Caddy / nginx / Traefik with Let’s Encrypt
  • Cloudflare Tunnel – no port forwarding, no public IP needed

See getting-started.md#remote-reachability.

Notifications without HA

LocalSky has four notification channels, none requiring HA:

  • Web Push – per-device, via VAPID. Works in any modern browser
  • ntfy.sh – free public service or self-host
  • Slack – incoming webhook
  • Email (SMTP) – planned

Configure under /settings/notifications. None of these touch HA.

Smart Irrigation? Irrigation Unlimited? Are those needed?

No. LocalSky’s engine is a complete, native replacement for both:

The clean-room rewrite was deliberate: both projects are excellent and were the prior art that proved this design space works. LocalSky absorbs their lessons + adds:

  • Multi-source weather merge with provenance
  • Native ET₀ from station readings (not just forecast model output)
  • Cycle-and-soak runoff prevention
  • A real first-run wizard
  • Settings UI
  • Multi-controller HAL
  • An LLM advisor

For an existing HA user already running SI + IU: see Mode 3 in getting-started.md. LocalSky’s ha_service_call controller can still dispatch through SI + IU if you’d rather keep your existing setup and use LocalSky for the dashboard + skip-rule engine only.

The “I’m against HA” reality check

Some objections to HA we hear and how LocalSky stands up:

ObjectionLocalSky position
“HA is too heavy for one feature”Agreed. LocalSky is one Docker container, ~30 MB resident.
“HA pulls in Python deps I don’t trust”Agreed. LocalSky is a single Rust binary; no plugin system, no eval’d YAML, no Python at all.
“I don’t want a YAML automation layer”Agreed. LocalSky’s logic is in compiled Rust + a typed TOML config; no automation YAML.
“I want a focused single-purpose tool”Agreed. LocalSky does irrigation + weather. That’s it.
“I’m worried HA’s roadmap will diverge from mine”Agreed. LocalSky is governed by its repo + its license; the project’s scope is irrigation forever.
“HA’s UX isn’t great for irrigation”Agreed. LocalSky’s UI was built for irrigation first, dashboard second.

LocalSky is for the user who wants the irrigation engine without buying into the broader home automation philosophy. If you’re an HA user already, LocalSky still plays well (Mode 2 / Mode 3); if you’re not, you’re not missing anything.

Already running HA? There’s a native integration

If you do run Home Assistant, LocalSky ships a native HA integration (installed via HACS) that creates LocalSky’s entities and services in HA directly over the REST/SSE API, no MQTT broker needed. See docs/hacs.md.

Summary table

CapabilityStandaloneHA + LocalSky (Mode 2: outbound)HA + LocalSky (Mode 3: HA-driven)
Weather dashboard
Engine (ET, weekly balance, skip rules)
Controller dispatch✅ direct✅ direct✅ through HA
Sensor ingestion✅ MQTT subscribe + direct adapters✅ same + HA passthrough✅ HA passthrough
Sensor entities visible in HA✅ via MQTT discovery✅ HA owns them
Native HA integration (HACS)❌ (no HA)✅ recommended over MQTT discovery
HA automations on LocalSky verdicts✅ via MQTT entities or the HACS integration✅ direct in HA
Web Push notifications
LLM advisor
Mobile PWA
Configuration surfaceLocalSky /settingsLocalSky /settingsLocalSky /settings
LocalSky depends on HANoNoYes (for dispatch only)

Pick the row that matches your current setup and your future direction.

Install as a Home Assistant App

This page is for Home Assistant OS (and Supervised) only. Apps (formerly add-ons) are a Supervisor feature, and the Supervisor exists only on those two installation types. Check yours in Home Assistant under Settings > About, the Installation method line:

  • Home Assistant OS or Supervised: you are in the right place.
  • Container or Core: there is no app store on your install. Run the LocalSky server with the Docker quick start instead; it is the exact same software, and everything else in these docs applies unchanged.

Docker is LocalSky’s preferred install method (the quick start), but several platforms are supported, and this app is the convenience option for Home Assistant OS: one click adds the repository, one click installs, and the Supervisor manages the container, updates, and backups from then on. It is the exact same released LocalSky image documented everywhere else in these docs, packaged for the app store.

Which piece is which

LocalSky on Home Assistant is always two pieces, and it is worth being precise about them:

PieceWhat it isWorks on
This appThe LocalSky server: data collection, irrigation engine, web UIHome Assistant OS / Supervised only
HACS integrationThe bridge that turns a running server into HA entitiesEvery HA installation type
Docker installThe same server, run anywhere Docker runsAny machine, HA optional

You always run exactly one server (this app or Docker, never both), plus the integration if you want entities in Home Assistant. Install the server first; the integration discovers it automatically over mDNS.

Requirements

  • Home Assistant OS or a Supervised installation (see the callout above)
  • An amd64 or aarch64 machine (Raspberry Pi 4/5, ODROID, generic x86)
  • A free TCP port 8090 on the host

Install

Add repository to my Home Assistant

Or manually: Settings > Apps > App store, open the overflow menu, choose Repositories, and paste:

https://github.com/silenthooligan/localsky-apps

Then install LocalSky from the store. Installs pull a prebuilt multi-arch image, so there is no local build step.

First run

  1. Start the app, then click OPEN WEB UI (the UI listens on port 8090).

  2. The first-run wizard walks you through station setup (Tempest or Ecowitt), location, zones, and your irrigation controller, exactly as in the Quick start.

  3. Optional but recommended: install the LocalSky integration for weather, soil, and irrigation entities in Home Assistant. One click adds it to HACS, and it discovers the running app on its own:

    Open your Home Assistant instance and add this repository to HACS

How the Home Assistant connection works

The app talks to Home Assistant through the Supervisor proxy. There is no URL to enter and no long-lived access token to create; device import and entity blending work out of the box. If you want a fully standalone server that happens to live on your HA box, turn the home_assistant option off.

Options

OptionDefaultWhat it does
home_assistantonConnect to HA through the Supervisor (device import, entity blending)
log_levelinfoServer log verbosity; debug/trace raise only LocalSky’s own namespaces

Everything else is configured in LocalSky itself, through the wizard and Settings. The app intentionally does not duplicate that configuration.

Networking

The app runs on the host network. That is required so it can hear the Tempest station’s LAN broadcast (UDP 50222), reach your Ecowitt gateway and OpenSprinkler controller, and announce itself over mDNS for integration discovery. The web UI binds host port 8090; if something else on the machine already uses it, the app log shows a bind failure at startup.

Data, backups, and updates

Everything LocalSky stores lives in the app’s /data volume: localsky.toml and the irrigation.db history database. That volume is included in Home Assistant backups, and the app stops briefly during a backup so the database is captured consistently. App updates appear in the store like any other app; the app version tracks LocalSky releases.

Troubleshooting

  • The app’s Log tab shows the server log at the configured level.
  • The watchdog probes /api/v1/info and restarts the app if the server stops responding.
  • Port 8090 already taken: free it or move the other service; the app currently uses a fixed port.

The app packaging itself lives at github.com/silenthooligan/localsky-apps; issues with LocalSky itself belong on the main tracker.

Home Assistant integration

LocalSky ships a native Home Assistant integration, distributed through HACS from github.com/silenthooligan/localsky-ha. It turns a running LocalSky instance into a first-class HA device: every weather reading, zone valve, soil probe, verdict, and threshold slider becomes an HA entity, and run/stop/pause become HA services you can call from automations.

LocalSky stays the brain. The integration is a thin client over LocalSky’s REST and SSE API; if HA goes down, watering continues unaffected.

The integration installs through HACS and works on every Home Assistant installation type (OS, Supervised, Container, Core). Only the server half differs by installation type: the Home Assistant app exists solely for OS/Supervised installs, while Docker covers every other setup.

Two pieces, in this order. LocalSky is a server you run yourself (one Docker container, see the Quick start, or one click as a Home Assistant App on HAOS); this integration is only the bridge that surfaces it inside Home Assistant. Installing the integration without a running LocalSky gives you nothing to pair with. Server first, integration second.

Pick one path into HA, never both. LocalSky can also publish entities through MQTT discovery (sensor.localsky_* via your broker). Running MQTT discovery and the HACS integration at the same time creates two copies of every entity. New setups should use the HACS integration; if you previously used MQTT discovery, disable LocalSky’s MQTT publishing and clear the retained homeassistant/.../config discovery topics before adding the integration (see Troubleshooting below).

What you get

One HA device per LocalSky instance, populated from LocalSky’s live entity manifest (GET /api/v1/sensors/manifest). Updates arrive over Server-Sent Events by default, so zone state changes show up in HA in under a second; a 30 second poll is the fallback. Adding a zone or sensor in LocalSky surfaces in HA automatically, no reconfiguration needed.

Requirements

  • Home Assistant 2024.11.0 or newer (enforced by HACS).
  • LocalSky app 0.7.0 or newer. The integration and app ship in lockstep from 0.7.0. The integration probes GET /api/v1/info during setup and refuses to pair with older instances (you will see a “service too old” error in the config flow), and it requires API version 1.12.0 or newer.
  • Network reachability from HA to LocalSky’s HTTP port (default 8090).

Install

1. Install from HACS

LocalSky is in the HACS default store:

  1. In Home Assistant, open HACS.
  2. Search for LocalSky and install it.
  3. Restart Home Assistant.

Or in one click (opens your own Home Assistant):

Open your Home Assistant instance and show LocalSky in HACS

2. Pair with your LocalSky instance

LocalSky announces itself on the LAN via mDNS as _localsky._tcp.local., so in most cases HA discovers it on its own: a “LocalSky” card appears under Settings > Devices & Services > Discovered. Click Configure and confirm.

If discovery does not fire (separate subnets, mDNS blocked), add it manually:

  1. Settings > Devices & Services > Add Integration, search for LocalSky.
  2. Enter the host (for example 10.0.0.100) and port (default 8090).

Pair against LocalSky directly on port 8090, not through a reverse proxy. If you front LocalSky with Caddy/nginx plus an auth gate, the gate’s redirects will break the integration’s API calls and the SSE stream. The proxy is for your browser; HA should talk to the instance directly on the LAN.

Options

After pairing, the integration card exposes three options (Configure on the integration entry):

OptionDefaultRange
Use SSE push updatesonon/off
Poll interval (fallback when SSE is off)30 s5 to 600 s
Default run duration for valve/switch open600 s60 to 7200 s

Authentication

If your LocalSky instance has an owner account (see authentication.md), the /api/v1/info probe reports auth_required and the config flow adds a token step.

Create the API token in LocalSky first, before adding the integration:

  1. In LocalSky, open Settings > Account.
  2. Under API tokens, create a token with a recognizable name (for example home-assistant).
  3. Copy the token; LocalSky shows it once.
  4. Paste it into the config flow’s token step. The integration validates it against GET /api/v1/auth/session before finishing.

If the token is later revoked, or you enable auth on a previously open instance, the integration receives a 401 and starts HA’s reauthentication flow: a repair issue appears asking for a fresh token. Create a new one in Settings > Account and paste it in.

Entity reference

Entity inventory comes from LocalSky’s manifest, so the exact set depends on your sources and zones. The tables below list what a typical install produces. Entity ids are generated by HA from the device and entity names; check Settings > Devices & Services > LocalSky for the exact ids on your install.

Weather

One weather.* entity built from the live station snapshot, with a 7 day daily forecast, plus individual sensors:

Sensors report in the app’s configured display units (Settings > Units: a household default that any device can override). Home Assistant then converts sensors with supported device classes (temperature, wind speed, precipitation, pressure, distance) to your HA unit system.

Entities come from the server’s sensor manifest, and since 0.7.14 the manifest only advertises what your install can actually feed: wet bulb, wind lull, rain last minute, illuminance, and station battery need a station source; precipitation probability needs a source that reports it; water level needs a controller that reports one; per-zone soil sensors need a soil probe on that zone. A sensor your install cannot feed is simply not created, and a created sensor whose value is momentarily missing reads unavailable instead of a fabricated number.

SensorUnit
Air temperature, feels like, dew point, wet bulb°F
Humidity%
PressureinHg
Wind speed, gust, lullmph
Wind direction°
Solar irradianceW/m²
UV index, illuminanceindex, lx
Rain today, rain last minute, rain intensityin, in/hr
Lightning strikes (last hour), average distancecount, mi
Station battery%

Irrigation

EntityPlatformNotes
Irrigation verdictsensortoday’s run/skip verdict from the engine
Irrigation reasonsensorthe human-readable “why” behind the verdict
ET₀ todaysensormm
Days since rainsensordays since significant rain
Rain tomorrow probabilitysensor%
Heat multipliersensorengine’s heat adjustment factor
Water levelsensorcontroller water level %; only for controllers that report one (OpenSprinkler)
Max wind, Min temp, Rain skipnumberskip-threshold sliders. The integration builds them at 0-50 mph, 20-60 °F and 0-1 in (about 0-80 km/h, -7 to 16 °C, 0-25 mm); LocalSky accepts 0-50 mph, 20-70 °F and 0-10 in, so every slider value is accepted. They do not convert to HA’s unit system; set them in imperial
HA reachablebinary_sensorconnectivity diagnostic
Irrigation suspendedbinary_sensoron while a pause is active
Any zone runningbinary_sensoron while any zone runs

Per zone

EntityPlatformNotes
valve.<zone>valvethe canonical control: open = run (default duration from options), close = stop
<zone> runningbinary_sensordevice class running
<zone> soil moisturesensorlive probe %; soil sensors are created only for zones with a probe
<zone> soil temperaturesensor°F, native Ecowitt probes
<zone> soil ECsensorµS/cm, native Ecowitt probes
<zone> soil batterysensorprobe battery %
<zone> planned runsensorseconds planned for the next run
switch.<zone> runswitchlegacy shim, disabled by default; prefer the valve

<zone> run today is no longer produced: nothing on any install sums a zone’s minutes since midnight, so the sensor that recorded 0 is gone. Delete it in Home Assistant if it lingers as unavailable.

Service reference

Five services, registered under the localsky domain. All accept an optional entry_id to target one instance when several LocalSky deployments are paired; without it the call fans out to every entry.

ServiceFieldsLimits
localsky.run_zonezone (slug, required), seconds (required)seconds clamped to 1-7200; LocalSky’s server enforces the same 2 hour cap
localsky.stop_zonezone (required)
localsky.stop_allstops every running zone
localsky.pausehours (default 24)1-720 hours; schedules and manual runs will not fire while paused
localsky.resumeclears an active pause

Example automations

Get notified when the engine decides to skip, with the reason:

automation:
  - alias: "LocalSky: notify on skip"
    triggers:
      - trigger: state
        entity_id: sensor.localsky_irrigation_verdict
        to: "skip"
    actions:
      - action: notify.mobile_app_your_phone
        data:
          title: "Watering skipped today"
          message: "{{ states('sensor.localsky_irrigation_reason') }}"

Give the dog-run zone a five minute rinse when a helper toggles:

  - alias: "LocalSky: quick rinse"
    triggers:
      - trigger: state
        entity_id: input_boolean.rinse_dog_run
        to: "on"
    actions:
      - action: localsky.run_zone
        data:
          zone: dog_run
          seconds: 300

Pause watering for three days when vacation mode turns on, resume on return:

  - alias: "LocalSky: vacation pause"
    triggers:
      - trigger: state
        entity_id: input_boolean.vacation_mode
        to: "on"
    actions:
      - action: localsky.pause
        data:
          hours: 72

  - alias: "LocalSky: vacation resume"
    triggers:
      - trigger: state
        entity_id: input_boolean.vacation_mode
        to: "off"
    actions:
      - action: localsky.resume

Outage behavior

  • LocalSky restarts or the network blips: the SSE streams reconnect automatically with backoff (2 s growing to 30 s). In polling mode, failed polls mark the entities unavailable until the next successful fetch.
  • HA restarts or goes down: nothing changes on the LocalSky side. Scheduling, skip rules, and controller dispatch all run inside LocalSky; HA is a window into the system, not part of the watering path. (The one exception is the ha_service_call controller, which routes valve commands through HA; see migrating-from-ha.md for why and how to move off it.)

Troubleshooting

LocalSky is not discovered. mDNS does not cross subnets or Docker bridge networks by default. LocalSky’s compose file runs with network_mode: host so the announcement reaches the LAN; if your HA and LocalSky sit on different subnets, skip discovery and add the integration manually with host and port.

Setup fails with “service too old”. The integration requires the LocalSky app 0.7.0 or newer (API 1.12.0 or newer). Upgrade the LocalSky container and retry.

Repeating 401 / reauth loop. The stored token is no longer valid. Open LocalSky Settings > Account, delete the old token, create a new one, and complete the reauth prompt in HA. If you are fronting LocalSky with a proxy auth gate, re-pair against port 8090 directly; the gate’s redirects can masquerade as auth failures.

Duplicate entities. You have both MQTT discovery and the HACS integration active. Choose one:

  • Keep the HACS integration (recommended): disable MQTT publishing in LocalSky’s config, then clear the retained discovery topics on your broker, for example mosquitto_sub -h <broker> -t 'homeassistant/#' --remove-retained --retained-only -W 5. That command removes every retained discovery topic on the broker, from every integration, not only LocalSky’s, which is what you want here (you are retiring the whole MQTT path) and is not what you want for a single stale sensor. The stale sensor.localsky_* MQTT entities disappear after an HA restart. Do this while MQTT publishing is still on if you can: LocalSky clears the retained topics it no longer publishes (the per-zone zone_<slug>_bucket_mm config and state topics, since 0.7.22), and it cannot clear anything once publishing is off.

    To clear one stale sensor rather than the whole tree, publish an empty retained message to just its two topics, per zone:

    mosquitto_pub -h <broker> -r -n -t 'homeassistant/sensor/<node_id>/zone_<slug>_bucket_mm/config'
    mosquitto_pub -h <broker> -r -n -t 'homeassistant/sensor/<node_id>/zone_<slug>_bucket_mm/state'
    

    homeassistant is the discovery prefix unless you changed it, and <node_id> is your deployment name, slugified.

  • Keep MQTT discovery: remove the LocalSky integration entry under Settings > Devices & Services.

Catalog status

The integration is in the HACS default store (accepted July 2026), so it installs by searching for LocalSky in HACS directly. Installs that predate the listing and were added as a custom repository keep working and keep receiving updates; there is nothing to migrate. The integration’s brand icon ships inside the integration itself and appears on Home Assistant 2026.3.0 or newer.

See also

Migrating your watering off Home Assistant

This guide is for people who run irrigation inside Home Assistant today, with integrations like Smart Irrigation, Irrigation Unlimited, the OpenSprinkler integration, or a vendor cloud (Rachio, Hydrawise, B-hyve), and want LocalSky to become the watering brain while HA stays the dashboard.

The end state looks like this:

  • LocalSky computes everything: ET from your weather, the weekly per-zone water balance, skip rules, and the morning schedule. Nothing it decides is read from Home Assistant. As of 0.7.22 that includes the skip thresholds and the four operator controls, which used to live in input_* helpers; see Upgrading to 0.7.22: your helpers stop deciding below.
  • LocalSky talks to your controller directly (OpenSprinkler, Rachio, Hydrawise, B-hyve, Rain Bird, MQTT), so watering works even when HA is down.
  • Home Assistant keeps everything it had, through one integration: the LocalSky integration publishes every sensor, zone valve, forecast, and the run/stop/pause services as native HA entities.
  • The old HA-side irrigation stack is removed, so your HA instance stops carrying duplicate logic and orphaned entities.

Nobody’s setup is identical; treat the steps as a checklist and skip what doesn’t apply.

Upgrading to 0.7.22: your helpers stop deciding

Seven Home Assistant helpers were still deciding when and whether your yard watered. 0.7.22 reads each of them one last time, writes the value into LocalSky’s own storage, and stops reading the entity.

HelperWhat it decidedWhere it lives now
input_number.irrigation_max_wind_mphThe wind skip thresholdSettings > Skip rules
input_number.irrigation_min_temp_fThe cold skip thresholdSettings > Skip rules
input_number.irrigation_rain_skip_inThe rain skip thresholdSettings > Skip rules
input_datetime.irrigation_pause_untilThe timed pause (Rain delay on the irrigation page)LocalSky’s own storage, set and released under Rain delay
input_select.irrigation_override_tomorrowTomorrow’s one-day overrideLocalSky’s own storage
input_boolean.irrigation_pauseThe pause switch (the Vacation pause toggle on the irrigation page)LocalSky’s own storage, set and released from the Vacation pause toggle
input_boolean.irrigation_dry_runDry-run modeLocalSky’s own storage

You do not have to do anything, and the value LocalSky takes is the value that was already deciding, so the first morning after the upgrade decides as the morning before it did. There are two exceptions, both spelled out below and both named on screen. A threshold helper set outside the range LocalSky can hold moves to the nearest value LocalSky can hold, and the migration notice names it and prints both numbers. And a vacation pause, pause switch or dry run already sitting in LocalSky’s own storage from a standalone era starts deciding again, because a Home Assistant deployment stored those and never read them; check Rain delay, the Vacation pause toggle and Dry run on the irrigation page after upgrading.

The three input_number helpers are worth understanding, because they outranked the matching thresholds in Settings whenever the helper existed. If your Settings page showed 10 mph while the helper held 12, the number deciding was 12 and the Settings page was decorative. After the upgrade Settings holds 12, LocalSky still uses 12, and editing Settings finally works. The irrigation page raises a one-time notice naming every entity, what LocalSky uses now, and both numbers wherever the two disagreed.

Nothing is deleted from Home Assistant. All seven helpers stay where they are, and they stop doing anything. Turning input_boolean.irrigation_pause on will not pause watering. If an automation writes to any of them, point it somewhere else or it will stop having an effect with nothing to show for it:

  • The three thresholds are number entities the LocalSky integration already publishes. Write those instead. LocalSky accepts 0 to 50 mph, 20 to 70 F and 0 to 10 inches, and refuses anything outside that with a message naming the range. The shipping integration builds the sliders from fixed ranges of its own (0 to 50 mph, 20 to 60 F, 0 to 1 in), all inside what LocalSky accepts; from manifest schema 1.6 an integration can take the bounds from LocalSky instead.
  • The pause, the one-day override and dry run have no entity of their own yet. Use POST /api/irrigation/action with an API token (see api.md).

Once you have repointed anything that wrote them, deleting the helpers is safe and changes nothing. As of 0.9.0 LocalSky reads none of the seven on any install: the one-time migration pass that copied their values across is gone along with the reads it gated. If your install never ran that pass (it upgraded straight from a release before 0.7.22, or ran without a persistence database), the values those helpers held did not carry over. Set the pause, the one-day override, dry run and the three thresholds in LocalSky; the migration notice names any control that was never recorded.

Some specifics worth knowing:

  • A threshold helper set outside the range LocalSky can hold moves to the nearest value it can hold. LocalSky holds 0 to 50 mph, 20 to 70 F and 0 to 10 inches. Setting input_number.irrigation_max_wind_mph to 99 is how people switch the wind gate off, and a helper’s own maximum is whatever you gave it, so nothing stopped one going past LocalSky’s range. Whatever it held was the number deciding, because the helper outranked Settings. It becomes 50 mph, which still means effectively never wind-skip; reverting it to the Settings value would have started skipping on the first breezy morning. The migration notice names any threshold this happened to and prints what the helper held beside what LocalSky is using.
  • A helper that is missing, or holding something that is not a number or a mode at all, is never adopted. It is recorded by name, LocalSky keeps what it had, and the read is retired anyway. For the three thresholds that moves nothing: a missing or unreadable helper already resolved to the Settings value, which is the value LocalSky goes on using.
  • The four controls are handled more carefully, because for them an absence is not the same as the value they hold. A control Home Assistant reports as unavailable or unknown is left alone: LocalSky keeps reading it and tries again later. That state means the helper exists and is briefly broken, which is what a helpers reload or a restore from backup looks like, and it reads the same on every poll, so waiting for a steady answer proves nothing about it. A control that is simply absent is concluded absent only after Home Assistant has answered identically, with an unchanging entity count, for five minutes. A vacation pause set in input_datetime.irrigation_pause_until is not dropped by an upgrade that lands in the middle of a Home Assistant restart.
  • A control LocalSky already holds its own answer for keeps that answer. An install that ran standalone and later gained HA_URL can still have old helpers sitting in Home Assistant; what you set in LocalSky is the more recent answer, so it wins and the helper is retired. This is the one place a control that was not deciding starts deciding: a Home Assistant deployment stored that value and never read it. A vacation pause counts as an answer only while it is still running, so an expired one is not kept and the helper’s pause is taken instead. The migration notice names every control this happened to, and says plainly when the result is that watering is held and where to release it.
  • Home Assistant being down during the upgrade holds nothing and stops nothing. The yard waters on the values it already has, exactly as in any other outage. LocalSky simply waits.
  • The one-day override no longer needs your midnight automation. LocalSky expires it at your own local midnight, stamping the day it was set on in your configured timezone. You can delete that automation. An override already sitting in LocalSky’s own storage when you upgrade reads as no override: it predates the day stamp, so there is no day it can honestly claim. Set it again if you still want it. One taken from input_select.irrigation_override_tomorrow by the migration is stamped with the day it was read, so it applies to that day and expires that midnight.
  • A Skip or Force set from the Override control still does not take effect on a Home Assistant deployment, and this release does not change that. The control writes it to LocalSky’s own storage and the Home Assistant snapshot builder fills the same field with “auto” on every tick, so the engine never sees it: the panel shows Skip while the yard waters. The defect predates this release, nothing stored in those two controls starts deciding here, and it is fixed on its own. Standalone installs are unaffected.
  • With no persistence database mounted, the three thresholds still migrate and the four controls do not, because a control needs somewhere to be kept. Their helpers keep working, and the migration notice names them and says not to delete them. Mount /data and restart to finish.
  • The migration record survives every way a config can be written. Rolling localsky.toml back to a snapshot taken before the upgrade restores the values in that snapshot and leaves the record in place, and so do saving in Settings, the raw config editor, restoring a backup taken before the upgrade, and re-running the setup wizard. The helpers do not go back in charge on any of them. That is deliberate: this release tells you the helpers are inert and invites you to delete them, so pointing a read back at one you have since deleted would read a live vacation pause as no pause. A backup restore is where that would have been worst, because the restored config takes effect immediately while the restored database only loads at the next restart.
  • The record of what happened is in localsky.ledger.toml beside the config (0.9.0 moved it there from [[ha_adoption]] in localsky.toml), permanently: entity, the value taken, the value it replaced, what the helper held if it had to be moved into range, and when.
  • A power cut in the middle of the migration cannot lose a value. The control values are written to LocalSky’s database and flushed to disk before the record is written to localsky.toml. A machine that dies between the two comes back with the values written and nothing recorded, so the next poll redoes exactly the same writes from the same reading and records them then. The reverse order would retire a read whose value never landed.

Reads that stay

  • A zone’s soil sensor. A zone you pointed at a Home Assistant entity still reads that entity. That is a sensor you named, not a decision being outsourced.

That is the whole list. Two reads that older releases documented here are gone in 0.9.0: the four legacy soil names (sensor.<zone>_soil_moisture for back_yard, front_yard, side_yard and back_yard_shrubs) went with the environment-variable zone list they served, and the nine sensor.open_meteo_* REST sensors are not consulted because every forecast figure, today’s modelled rain included, now comes from LocalSky’s own forecast. You can delete all of them.

Upgrading to 0.7.22: your zones may start watering

Read this before you upgrade if LocalSky is already talking to Home Assistant.

Until 0.7.22, run lengths on a Home Assistant deployment were sized by a Smart Irrigation entity (sensor.smart_irrigation_<zone>) and by nothing else. If you do not have that HACS integration, LocalSky read the absent entity as a zero deficit, planned zero minutes on every zone, and dispatched nothing, on every morning since you installed it. Your yard has been watering on whatever else you had, or not at all.

0.7.22 sizes runs from LocalSky’s own weekly water budget on every deployment, so those zones start watering on the first morning after the upgrade.

If you never set a zone’s weekly target and sessions per week, LocalSky infers them from the zone’s species: 1.00 inches a week over two sessions for warm-season turf, scaled by each species’ own peak crop coefficient, so established shrubs start at 0.55 inches over one session and a vegetable bed at 1.15 inches over two. Each session is held to the zone’s maximum run time, 60 minutes unless you changed it, and on a default zone the first eligible morning lands on that ceiling.

If you had tuned input_number.irrigation_<zone>_weekly_budget_in or input_number.irrigation_<zone>_sessions_per_week in Home Assistant, those two values are not carried across: LocalSky stops reading them and uses the zone’s own config, or the inferred default. Enter them as Weekly target and Sessions per week on the zone.

Do this before the next morning window:

  1. Open Settings, then Zones, open each zone and set Weekly target and Sessions per week; blank shows the inferred default in the box. Check Max run time there too.
  2. If you want everything held while you review, set Rain delay on the irrigation page first, long enough to cover the review. On a Home Assistant deployment that writes input_datetime.irrigation_pause_until, which this release adopts with the pause intact, so the hold survives the upgrade; release it under Rain delay afterwards rather than in Home Assistant. Do not use the Override control for this on a Home Assistant deployment: a Skip stored there decides nothing, as the Override control bullet above says.

The Irrigation page and the Zones page raise a one-time notice listing every zone that is watering on an inferred target and the target it will use, and the zone list under Settings marks the same zones. Dismiss the notice once every zone carries a target you set. On a Home Assistant deployment LocalSky also logs a warning naming those zones the first time after a start that it plans a run for one, and sends one push notification to subscribed devices.

Phase 1: Stand LocalSky up next to what you have

Nothing breaks in this phase; you’re adding, not replacing.

  1. Install LocalSky (Docker or the binary) and run the setup wizard: location, weather sources, your controller, zones.
  2. Controller: add it natively (the wizard can scan it for stations). This does not interfere with an existing HA integration reading the same hardware; both can watch it at once.
  3. Sensors: if some sensors only exist in HA (a Zigbee soil probe, a Z-Wave rain gauge), add an HA passthrough source (kind = "ha_passthrough") and map those entities. Supported hardware can also be read directly. If HA already reads your Tempest, choose the HA WeatherFlow path below, especially when both applications share a host. See sensors.md for mappings.
  4. Install the LocalSky integration in HA, following hacs.md: search for LocalSky in HACS and install it. One gotcha: if your LocalSky has an owner account, create an API token in LocalSky (Settings > Account) before adding the integration, because the config flow asks for it. After that, it discovers the instance on your network; entities appear immediately.

Keeping WeatherFlow in Home Assistant

LocalSky can use HA’s WeatherFlow sensor readings while HA keeps receiving Tempest broadcasts. The WeatherFlow integration reads the station; LocalSky’s HA passthrough source reads its sensor entities through HA’s REST API. The optional LocalSky integration in HA exports LocalSky’s results in the opposite direction. Do not map those LocalSky exports back into its inputs.

  1. In Settings > Devices, disable or remove LocalSky’s Tempest UDP source. Its socket closes on the next configuration check, normally within 15 seconds. This also persists across restart; LocalSky does not require a direct listener.
  2. If HA’s WeatherFlow integration failed because the port was occupied, reload that integration after LocalSky releases it. Confirm HA’s station sensors update.
  3. Add an HA passthrough source in LocalSky. Enter the HA base URL and a long-lived token. Use Field mappings to select each reading and enter the corresponding WeatherFlow sensor entity ID. HA’s declared units are converted automatically; do not convert them a second time.
  4. In Settings > Devices, find Which source provides each reading and choose that HA source for the desired readings. Per-reading preferences and ordered fallbacks express your intent directly. The configured source priority controls automatic ordering among eligible sources. The generic add-source form starts at 50; legacy environment synthesis uses 30 for HA. Existing priorities are preserved. A particular number is not required to enable HA, and a forecast fill cannot displace a fresh live reading just because its numeric priority is higher.
  5. Restart LocalSky after saving the changed sources. A newly added HA connection starts at boot; the restart also clears the source-change watering hold.
  6. Keep a forecast provider enabled. HA passthrough polls current sensor states every 30 seconds and does not import forecasts or the full native event stream.

HA being reachable does not make an old sensor reading fresh. LocalSky preserves each entity’s last_reported timestamp, including unchanged values that HA reports again. If an older HA version omits that field, it uses last_updated conservatively. Missing, invalid, future-dated or restored states are not current observations. The configured source age limit determines when another source or a watering hold takes over. A new temperature report cannot refresh an old rain or soil reading.

Map WeatherFlow precipitation to Rain last minute. HA’s local WeatherFlow sensor reports the preceding minute’s accumulation. LocalSky converts its units, adds each observation once and restores today’s recorded minutes after restart. WeatherFlow’s last_reset identifies the physical report; repeated HA polls or republishing that state cannot count it again. Totals stay separate for each source and reset at midnight in LocalSky’s configured timezone. HA documents the precipitation interval.

Use Rain today only for an entity that already supplies a daily total; choose one rain mapping per source. LocalSky cannot reconstruct minute reports missed while it or HA was offline. Accumulation starts with reports actually received, so an existing daily-total entity remains useful when upstream outage recovery is needed. Predicted rain stays separate from measured rain. For sustained wind, map WeatherFlow’s wind average; its wind speed entity also receives rapid samples.

Illuminance, lightning count and lightning distance can also be selected in Field mappings. These are sampled sensor values, not individual lightning events. Disabling LocalSky’s native listener trades its direct update cadence and event coverage for the readings HA exposes; the engine uses the resulting evidence and freshness normally.

Phase 2: Watch them disagree

Run both brains side by side for a few days. LocalSky’s Irrigation tab shows tonight’s plan, every zone’s verdict, and the “why” behind each number (Settings has a Simulator and Rule Lab for what-ifs). Compare against what your HA setup decides. Tune species, soil texture, and sprinkler rates in LocalSky’s zone settings until you trust its plan.

Expect a few settling days before the numbers converge. LocalSky sizes runs from a rolling seven-day water balance, and on a fresh install it has no rain ledger and no recorded runs yet, so the first plans lean on the forecast provider’s past-day archive alone. Don’t tune against day one; give it several days of weather, rain, and recorded runs before comparing seriously.

While you’re watching, make sure the old system is the only one with a live schedule. LocalSky doesn’t actuate anything until its controller is enabled with zones assigned, but it’s worth confirming you don’t have two schedulers armed.

Phase 3: Flip the brain

  1. Disarm the HA-side scheduler first so nothing double-waters:
    • Irrigation Unlimited: turn off the controller master switch (switch.irrigation_unlimited_c1_m) or set enabled: false on its schedules.
    • Smart Irrigation: disable the automation that applies its duration to your valves.
    • Vendor apps (Rachio/Hydrawise/B-hyve): disable the schedule in the vendor app; leave weather skip features off so they don’t fight LocalSky.
  2. In LocalSky, confirm the controller is enabled and every zone is mapped to a station.
  3. LocalSky schedules the next morning run automatically; the Irrigation tab shows when and why.
  4. Watch one full watering cycle. The History tab records every run and skip with the reason.

Rollback is symmetric: re-enable the old schedule and disable LocalSky’s controller. Nothing in this guide deletes data until Phase 4.

When Home Assistant is unavailable

The point of the flip is that HA stops being a single point of failure for watering. What actually happens during an HA outage depends on which LocalSky pieces still touch HA:

PieceBehavior while HA is down
Direct controllers (OpenSprinkler, Rachio, Hydrawise, B-hyve, Rain Bird, MQTT)Unaffected. LocalSky talks to the hardware itself; schedules run normally.
HA passthrough source (kind = "ha_passthrough")LocalSky polls HA’s /api/states every 30 seconds. A request failure marks the source unreachable. Sensor report times remain independent of HTTP success, so frozen values expire under the source’s age limit. Other configured sources can supply the missing fields. Missing required weather or an unavailable configured soil probe can hold watering; the displayed reason explains which evidence is missing.
ha_service_call controllerEvery valve command is an HTTP call into HA. With HA down the dispatch fails: LocalSky logs the failure, abandons that zone’s remaining cycle segments, moves on to the next zone, and does not retry until the next scheduled window. Nothing waters through this controller during the outage, which is exactly why this guide moves you onto a direct controller.

Phase 4: Clean up Home Assistant

Once you trust LocalSky, remove the old stack so HA stops carrying noise. Order matters: dashboards first, then integrations, then leftovers.

  1. Repoint dashboards and automations. Anything referencing the old integration’s entities (zone switches, “running” sensors, duration numbers) has a LocalSky equivalent entity now. Swap references before removing integrations so tiles don’t break.

  2. Remove the integrations. Settings > Devices & services: remove the Smart Irrigation / OpenSprinkler / vendor config entries. For YAML-configured Irrigation Unlimited, delete its YAML block and restart.

  3. Remove the HACS components. HACS > installed: remove Smart Irrigation, Irrigation Unlimited, and their dashboard cards (e.g. irrigation-unlimited-card) if nothing else uses them.

  4. Sweep for orphans. Settings > Entities, filter by the old integration names; HA marks removed integrations’ leftovers as unavailable. Remove them. Developer tools > Statistics also lists orphaned long-term statistics you can purge.

    Purging statistics is irreversible. Once you delete an entity’s long-term statistics, years of recorded history for that entity are gone with no undo. If any of it matters (seasonal water usage comparisons, ET history), export it first, or just leave the orphans; they cost almost nothing.

  5. Keep: the LocalSky integration, and the HA passthrough source only if it still feeds sensors that exist nowhere else.

  6. Delete when you are ready: the seven input_* helpers LocalSky used to read. As of 0.7.22 they decide nothing, LocalSky holds every one of their values itself, and deleting them changes nothing. Two exceptions: on an install with no persistence database mounted, the four control helpers were never taken over and are still deciding, and on an install with no localsky.toml the migration has not run and all seven are. The migration notice says so in either case; do not delete those until /data is mounted, or the setup wizard has written a config, and LocalSky has run the migration. They are not orphans and step 4’s sweep does not cover them, so they will sit there until you remove them by hand. Before you do, check that no automation writes to one: it will keep firing and stop having an effect. See Upgrading to 0.7.22: your helpers stop deciding.

What about the controller’s own HA integration?

After the flip, an OpenSprinkler/Rachio/Hydrawise HA integration is redundant: LocalSky publishes the same zones and state, and having two write paths to the hardware invites conflicting commands from old dashboard buttons. Recommended: repoint dashboards to the LocalSky entities and remove the controller’s HA integration. Keep it only if you have automations that talk to controller features LocalSky doesn’t expose.

Quick mapping reference

You hadLocalSky equivalentWhere it’s documented
Smart Irrigation ET calculationsNative ET engine (FAO-56 ET0) feeding the weekly per-zone water balanceirrigation-engine.md
Smart Irrigation seasonal adjustmentThe seasonal dial under Settings, applied to every zone’s planned minutes. Kc curves per species and the heat multiplier are computed and shown, but they size no runzone-math.md
Irrigation Unlimited schedulesSmart-morning scheduler + per-zone budgetsirrigation-engine.md
Irrigation Unlimited sequencesThe morning run is a sequence: zones dispatch one after another, with cycle-and-soak splitting per zoneirrigation-engine.md
Multiple schedules per zoneManual schedules alongside the smart scheduler, plus per-zone weekly budget and sessions-per-weekconfiguration.md
HA automations for rain skipSkip rules + Rule Lab (Settings > Logic)skip-rules.md
Vendor app weather skipForecast-aware verdicts, visible per zoneverdict-strip.md
Rain delay buttonPause/resume: the dashboard pause control or localsky.pause / localsky.resume from HAhacs.md
Manual-run services / scriptslocalsky.run_zone and localsky.stop_zone services, or open the zone’s valve entityhacs.md
Zone switches in HAvalve.<zone> via the integration (a legacy switch shim exists, disabled by default)hacs.md
“Is it running” sensorsPer-zone running binary_sensor via the integrationhacs.md

Devices

Settings, Devices is the single hub for everything LocalSky talks to: every controller, source, and sensor, whether LocalSky owns it natively or sees it mirrored from Home Assistant. If you only remember one screen for hardware, remember this one. The companion Sensors page is just a lens that filters this same set down to the probes and meters.

The three tiers

LocalSky groups hardware into three tiers, and keeping them straight makes the rest of the UI obvious:

  • Controllers open and close valves. Your OpenSprinkler, or the Home Assistant service that fronts your valves, is a controller. This is what actually waters. See Irrigation controllers for the supported list and per-kind configuration.
  • Sources (also called gateways) bring data in. A weather station, an Ecowitt gateway on your LAN, a forecast provider, an MQTT broker, or a Home Assistant bridge: each is a source. A source is a pipe, not a probe.
  • Sensors are the individual probes and meters those sources carry. A soil-moisture probe paired to an Ecowitt gateway is a sensor on that gateway; a flow meter wired to your OpenSprinkler is a sensor on that controller.

So a sensor never connects to LocalSky directly. It rides in through a source or sits on a controller. Add the source or controller here, and its sensors show up underneath it, ready to use. The Add your first soil sensor walkthrough follows this model end to end.

Native vs Home Assistant

Every device card is tagged with its origin:

  • Native devices are ones LocalSky owns directly: a source or controller you added here. Native devices are editable in place. Click Edit on the card to open the same source or controller editor used elsewhere, change it, and save; the device registry hot-reloads shortly after. You can also enable or disable a source with the toggle on its card, which controls whether it contributes to weather readings without removing its configuration.
  • Home Assistant devices are mirrored in from a configured HA bridge. They are read-only here, because HA owns them. The card says “Managed in Home Assistant” instead of an Edit button. To change one, change it in HA; the mirror follows.

A native device that also exists in Home Assistant carries a small + HA badge, so you can tell at a glance that the same physical thing is visible on both sides without it being a duplicate. Cards also show an Online or Offline pill when LocalSky has a reachability signal, and a small badge with a count of how many items the device carries. Expand the card to see what those items are: the sensors and zones the device brings in, broken out as child rows.

Adding a device

The Add a device bar gives you two direct paths and one discovery path:

  • Weather source: opens the source editor. Pick a kind (Ecowitt gateway, MQTT, a forecast provider, a Home Assistant passthrough, and so on), fill in its connection details, and save.
  • Controller: opens the controller editor. Pick a kind and configure it. Exactly one controller is the default; new zones inherit it.
  • Scan network: sweeps the LAN for supported gateways (Ecowitt today) that broadcast on your network.

Sources you add join one unified list of every source LocalSky knows about, each with live status and an enable/disable toggle. Cloud services available in your region that you have not enabled yet appear separately as “coverage you can add”: toggle one on to start using it immediately.

Scan and adopt

The fastest way to add an Ecowitt gateway is to let LocalSky find it:

  1. Click Scan network. LocalSky listens for supported gateways broadcasting on the LAN.
  2. Each gateway it finds shows up as a Discovered card with its model, IP, and MAC address.
  3. Click Adopt as source. That opens the source editor prefilled with the gateway’s host and a sensible poll interval, so you usually just confirm and save.
  4. Once saved, LocalSky starts polling the gateway, and its soil channels appear as sensors under it (visible here and on the Sensors page), ready to bind to a zone.

If a scan finds nothing, the gateway may not be on the same subnet, or it may not broadcast; in that case add it by hand with Weather source, choosing the Ecowitt gateway kind and typing the IP into the host field.

Per-reading source priority and backup chains

Each headline reading (temperature, humidity, wind, rain, pressure, solar/UV) has its own ordered chain of sources. The first source in the chain that is reporting fresh data owns the reading; if it goes quiet the next takes over, so a reading is never lost while any source in its chain is fresh. “Automatic” is the smart default order for your region and enabled sources. To customize it, open Settings > Devices, find the section titled Which source provides each reading, and drag a reading’s source rows into the order you want (or use the up and down arrow keys); that becomes “Custom”. The order you set is exactly the priority the engine uses for that reading. A one-source chain behaves like a single hard pin.

Where to go next

Add your first soil sensor

This is the plain-language walkthrough for getting one soil-moisture reading into LocalSky and using it to gate a zone. No YAML, no terminal. If you have never wired a sensor before, start here; if you are a pro, skip to the path that matches your hardware.

The model: controllers, gateways, sensors

LocalSky talks about hardware in three tiers. Keeping them straight is the one thing that makes the rest obvious:

  • Controllers open and close valves. Your OpenSprinkler (or the Home Assistant service that fronts your valves) is a controller. This is what actually waters.
  • Sources (also called gateways) bring data in. A weather station, an Ecowitt gateway on your LAN, a forecast provider, an MQTT broker, a Home Assistant bridge: each is a source. A source is a pipe, not a probe.
  • Sensors are the individual probes and meters those sources carry. A soil-moisture probe paired to an Ecowitt gateway is a sensor on that gateway. A flow meter wired to your OpenSprinkler is a sensor on that controller.

So a soil probe never connects to LocalSky directly. It rides in through a source: an Ecowitt gateway polls it, an MQTT topic carries it, or Home Assistant already owns it and LocalSky reads it from there. Add the source first, and its sensors show up underneath it, ready to bind to a zone.

You manage all of this in two places. Settings, Devices is the unified hub where you add sources and controllers, each with live status and an enable/disable toggle, and see every device LocalSky knows about (plus any you already have in Home Assistant). Settings, Sensors is the lens that lists just the probes and meters, grouped by the source or controller they arrive through, with the control to bind each one to a zone.

The three ways in

There are three supported paths for a soil probe. Pick the one that matches what you have:

  1. Ecowitt gateway on your LAN (recommended, and the cheapest). Native, no cloud, no broker.
  2. Any MQTT-published probe (ESPHome, Tasmota, Zigbee2MQTT, a DIY ESP32). Needs an MQTT broker.
  3. Any Home Assistant soil entity (a Zigbee or Z-Wave probe HA already knows about). Needs an HA bridge.

An Ecowitt WH51 (or WH52) soil probe is battery-powered, costs a few dollars, and pairs wirelessly to an Ecowitt gateway (GW1100, GW2000, and similar). LocalSky polls the gateway directly over your LAN and reads every soil channel natively: moisture, temperature, conductivity, and battery, per probe.

How the pieces fit: the probe pairs to the gateway (in the gateway’s own WS View app), and the gateway sits on your LAN. LocalSky polls the gateway, not the probe. So the probe is a sensor, the gateway is the source, and you only ever add the gateway to LocalSky.

Steps:

  1. Find the gateway’s IP. Open the Ecowitt WS View app (the one you used to set up the gateway), or check your router’s client list. It looks like 10.0.0.50.
  2. Pair your soil probes to the gateway if you have not already, again in WS View. Each probe claims a soil channel (1, 2, 3…).
  3. In LocalSky, go to Settings, Devices and click Scan network. LocalSky finds Ecowitt gateways broadcasting on your LAN and offers an Adopt as source button. (No gateway found? Click Weather source, pick Ecowitt gateway (poll), and type the IP into the host field by hand.)
  4. Save. LocalSky starts polling the gateway every 30 seconds.
  5. Go to Settings, Sensors. Each soil channel the gateway reports now appears as a card under the gateway, with its live moisture reading.
  6. Bind a probe to a zone with the “Bound zone” dropdown on that card. See Binding a probe to a zone below.

Calibration is optional. The gateway reports a moisture percentage on its own, but if you want LocalSky to compute it from the probe’s raw reading (more accurate for your specific soil), the source editor has a Soil channel calibration form: pull the probe out and read its dry value, soak it and read its wet value, enter both, and LocalSky maps everything in between to 0 to 100 percent.

Path 2: any MQTT probe

If your probe (or a hub like Zigbee2MQTT) publishes to an MQTT broker, LocalSky can subscribe. This covers DIY ESP32 capacitive probes on ESPHome, Tasmota devices, and anything on Zigbee2MQTT.

You need an MQTT broker on your network. Mosquitto is free and tiny; if you already run Home Assistant you almost certainly already have one.

Steps:

  1. Publish soil moisture to a topic from your probe, for example zigbee2mqtt/garden_soil or esp/soil/back. Note whether the payload is a bare number or a JSON object.
  2. In LocalSky, go to Settings, Devices, click Weather source, and pick MQTT.
  3. Fill in the broker host, port, and credentials.
  4. In the Soil subscriptions form, click + Add soil subscription and set:
    • MQTT topic: the topic your probe publishes to (wildcards + and # are allowed).
    • JSON field: if the payload is a JSON object, the field that holds the moisture value (for example soil_moisture). Leave blank if the payload is just a number.
    • Bind to zone: the zone this probe measures. This records the topic as that zone’s own soil channel so it is not merged into general humidity.
    • Leave Reading on “Soil moisture” unless this same topic also carries temperature or another reading you want.
  5. Save. The subscription starts immediately.
  6. Go to Settings, Sensors to confirm the probe is reading, then finish wiring it in the zone (the MQTT form binds the topic to the zone; the zone editor picks that channel as its soil sensor). See Binding a probe to a zone.

Path 3: any Home Assistant soil entity

If Home Assistant already owns a soil probe (a Zigbee probe on ZHA, a Z-Wave probe, anything that shows up as a sensor.* moisture entity in HA), LocalSky can read it through an HA bridge. Nothing re-pairs; HA stays the owner.

Steps:

  1. Create a long-lived access token in HA: your HA profile, Security, Long-lived access tokens, Create token. Copy it.
  2. In LocalSky, go to Settings, Devices, click Weather source, and pick HA passthrough.
  3. In the Connection form, fill the Home Assistant URL field with your HA address (for example http://10.0.0.10:8123) and the Long-lived token field with the token you just copied. Save. That bridge is now a source.
  4. Open Settings, Zones, pick the zone, and in the Soil moisture sensor dropdown choose your HA probe. It appears in the list as ha:sensor.<your_entity> (the picker reads HA’s entity list using the credentials from step 3).

That is it: an HA soil entity is bound straight from the zone editor, because the HA bridge is the source and HA already enumerates the probe for you.

Binding a probe to a zone

A probe does nothing until it is bound to a zone. Binding tells the engine “this reading is the truth for this zone’s moisture,” and the engine stops guessing from the weather model alone for that zone.

Where you bind depends on the source:

  • Ecowitt and HA probes bind in one step. Use either Settings, Sensors (each probe card has a Bound zone dropdown) or Settings, Zones (open the zone, pick the probe in its Soil moisture sensor dropdown). The binding saves immediately and the engine uses it on the next tick.
  • MQTT probes are a two-step. First, in the source’s Soil subscriptions form, set the subscription’s Bind to zone: that registers the topic as that zone’s own soil channel. Then go to Settings, Zones, open the zone, and confirm that channel in its Soil moisture sensor dropdown.

One probe maps to one zone. Re-binding a probe to a different zone releases it from the old one automatically.

How the engine uses a bound probe:

  • Below the zone’s target band: the zone is eligible to water. Run length is unchanged; it comes from the weekly water balance.
  • Inside the band: healthy; scheduled runs still apply unless the saturation threshold says otherwise.
  • At or above saturation: the zone skips on its own, even when the day’s overall verdict is Run, and the skip reason names the zone’s moisture reading and the saturation threshold (for example, “Soil saturated (76% ≥ 65% threshold)”).

If a bound probe goes offline, the zone simply loses its soil gate. Nothing blocks; a missing probe never stops a run, and run sizing is unaffected because the weekly water balance never reads a probe. If a probe reads as a wild outlier versus its neighbors, or goes offline entirely, the irrigation and zones views flag it as an anomaly so you know to check the hardware.

A note on flow meters: capable vs connected

Flow metering is a separate idea from soil moisture, and it lives on the controller, not on a gateway. The Sensors view distinguishes two states so the wording is never misleading:

  • Capable: your controller type supports a flow input (OpenSprinkler does). The Sensors view shows “Flow meter supported. None connected.”
  • Connected: a physical flow sensor is wired to that input and reporting. Then the view shows the live gallons per minute.

To go from capable to connected on an OpenSprinkler: wire a pulse flow sensor to the controller’s FLOW input, set the K-factor on the device, and LocalSky reads it automatically. Once a flow meter is connected it validates that each run delivered the water the engine asked for and flags leaks (flow with no zone running).

Where to read more

Sensors

LocalSky’s engine produces useful output with just weather and a location. Every sensor you add unlocks more behavior, but nothing is required. The dashboard shows empty states with “connect a sensor to unlock X” affordances where data would otherwise live.

For standalone (no HA) users: the question “how do my sensors get into LocalSky without HA?” has a thorough answer in docs/standalone.md. Short version: run any MQTT broker (Mosquitto is free, 5 MB), point Tasmota / ESPHome / Zigbee2MQTT at it, and LocalSky’s mqtt source subscribes to the topics you configure. HA never touches it.

Always-on baseline (no sensors required)

Just from weather forecasts + your latitude/longitude, LocalSky computes:

  • FAO-56 reference ET₀ (Hargreaves fallback when only temp range is available; Penman-Monteith when wind + solar + humidity show up)
  • Crop ET per zone from species-specific Kc curves
  • Weekly per-zone water balance sizing every run
  • 24-rule skip ladder (rain forecast, freeze, wind, already-wet, etc.)
  • 7-day verdict strip projection
  • Cycle-and-soak runtime splitting

The dashboard renders cleanly with this alone. The verdict tile shows green/yellow/red, the zone cards show planned next-run, the weather panels show forecast data, the radar shows local conditions.

Receiver sources (push, not poll)

Most sources are ones LocalSky reaches out to on a timer. Three work the other way around: the hardware decides when to talk, and LocalSky records what shows up. You host all three yourself, so none of them needs a vendor cloud in the middle.

  • Ecowitt LAN push (ecowitt_local): the gateway posts form-encoded readings to /ingest/ecowitt on the interval you set in its own Weather Services > Customized screen. Each source’s optional shared secret decides whether it accepts a given report, and LocalSky reads that secret out of the posted form body under PASSKEY, passkey, or key. A secret appended to the upload URL is never looked at. Several gateways can post to the same endpoint and each report reaches the weather merge through every source whose secret it matches, but the per-field readings log and the soil channels the zone picker binds are recorded only for the first ecowitt_local source in your config, gated on that source’s secret and stamped with its id. Soil probes on a second gateway are attributed to the first source or dropped, so keep soil on one gateway.
  • HTTP webhook (http_webhook): anything that can POST JSON (a commercial station, a Pi, a cron script) posts to /ingest/webhook/<source id>, where the last path segment is the source’s own id. An optional token rides in ?token= or the X-LocalSky-Token header.
  • MQTT (mqtt): LocalSky subscribes to the topics you list on your broker and maps each topic to a field. This one opens the connection rather than waiting for it, but the rhythm is the same: the sensor publishes on its own schedule and LocalSky takes what arrives.

Both HTTP endpoints are mounted twice, under /ingest/... and under /api/v1/ingest/..., so a device already pointed at either address keeps working.

Add any of them from the Sensors page with Add a data source. In the kind picker, the Ecowitt receiver sits under “Local weather station” while MQTT and the webhook sit under “Sensors & data bridges”. The per-kind wiring, broker included, is in standalone mode.

Confirming it works. Open the source on the Sensors page. A receiver logs every field it takes in, so its detail pane lists the latest value per key with the age of the newest one, which is how you tell live data from a hopeful configuration. Before the first report lands, an Ecowitt push or webhook source says so and names the endpoint to point the device at. Read that log rather than the HTTP response: once at least one ecowitt_local source exists, an Ecowitt gateway gets a 200 back on every POST whether or not the reading was kept, because a gateway that sees anything else starts retrying in a storm. With no ecowitt_local source configured, which is what a gateway pointed at LocalSky before you add the source hits, the endpoint answers 503 instead. Two things make a report vanish quietly. A shared secret configured on the source that the gateway does not send drops the reading, and a wrong webhook token, or a payload that maps to no field, answers 422. A polled or cloud source logs its readings here too, so the same pane fills in once it has posted a cycle. The line about keeping no separate per-field log is the fallback the pane shows for a source that is not an Ecowitt push or webhook receiver, an MQTT source waiting on its first message included, and when you see it the source’s status word is how you check on it.

Hardware you did not point at LocalSky by hand shows up on the Devices page instead. That is the one hub for every source and controller, and it carries the Scan the network button that finds Ecowitt gateways on the LAN.

Optional sensors and what they unlock

Soil moisture probes

Examples: Ecowitt WH51 / WH52 (battery), Aqara Zigbee, Sonoff Zigbee, capacitive-soil-moisture sensors on ESPHome.

Unlocks:

  • Yard-wide saturation skip rule: when every zone reports moisture at or above its saturation threshold, the engine skips the run.
  • Per-zone soil moisture display: a horizontal bar per zone showing current moisture vs. target band.
  • Soil-moisture projection: 7-day forward curve under no-irrigation, color-coded for “stays in healthy band” vs. “will dry out”.
  • Smarter dry-out detection: catches the case where ET-based math underestimates actual drying (heavy clay holding water visibly longer than expected, or sandy spots draining faster).
  • Anomaly detection (new in 0.7.0): a probe that goes offline, or reads as a wild outlier versus its neighbors, is flagged on the irrigation and zones views so you know when to check the hardware.
  • Tuning calibration checks: the tuning report compares the probe’s drying rate against the configured soil model and backs the real sprinkler rate out of the probe’s rise across waterings. Both need LocalSky’s own probe history, so they unlock for a zone bound to a source: channel: a gateway poll, the Ecowitt LAN push receiver, an MQTT subscription, a webhook field, or a Home Assistant entity routed through the passthrough source’s soil_zone_map. A zone bound straight to a live ha:<entity_id> pick has no local history and gets neither check.

Connect via: the native Ecowitt gateway poll, the Ecowitt LAN push receiver, an MQTT subscription or webhook field bound to a zone, or any Home Assistant soil entity. Once the readings are flowing, assign each probe to its zone; see Assigning soil probes to zones below.

Soil temperature probes

Examples: Ecowitt WH51 (same physical probe as moisture), Aqara temp/humidity in the ground.

Unlocks:

  • Soil-frost skip rule: spraying frozen ground freezes water on contact. Soil temperature lags air temperature substantially; the engine catches the “cold soil + sunny morning” case better than air-temp alone.

Discrete rain gauge

Examples: Ecowitt RG200, AcuRite tipping bucket, RainWise.

Unlocks:

  • Higher rain-today accuracy when your weather station’s onboard gauge is less reliable than a dedicated unit (or you don’t have a weather station at all).
  • Merge engine takes the max across rain sources, so adding a gauge can only improve accuracy.
  • Honesty labels (new in 0.7.0): every reading carries an honesty label so you know how it was obtained: measured (a real gauge or station), radar (live Doppler, for example NOAA MRMS rain), real-time nowcast, or model forecast.

Lightning detector

Examples: Tempest hub (built-in), Ecowitt WS6006, RainWise.

Unlocks:

  • Lightning panel: shows last-strike distance + count over last 3 hours.
  • Safety skip during active storms: paired with the existing rain rule; the engine doesn’t fire valves when there’s active lightning within a configurable radius (planned).

Flow meter on the controller

Examples: OpenSprinkler flow meter input, Rachio flow sensors.

Unlocks:

  • Actual-delivered-water validation: compares the flow-meter reading to the engine’s computed mm depth. A discrepancy >20% indicates a stuck valve, a busted line, or a calibration drift.
  • Leak detection: flow at zero-zones-running is a leak; the engine alerts.
  • Per-zone precipitation rate auto-calibration (planned): the catch-cup measurement is replaced by automatic estimation from flow + zone area.

Ambient air-quality / pollen / PM2.5 (display only)

Examples: PurpleAir, AirGradient, Ecowitt WH41.

Unlocks:

  • Display tiles only. The engine doesn’t make irrigation decisions on air quality (yet).

Assigning soil probes to zones

Wire a moisture probe to a zone and the engine gains a measured gate: a saturated zone skips on its own, and a measured-dry zone can override a soft forecast-rain skip.

Supported paths in:

  • Ecowitt soil probes (WH51 and friends) via a LAN gateway: native, no cloud. The ecowitt_gw_poll source polls the gateway directly and records moisture, temperature, conductivity, and battery per probe; the ecowitt_local push receiver works too.
  • Any Home Assistant soil sensor entity: a Zigbee probe on ZHA, a Z-Wave probe, anything HA already knows about.
  • A DIY probe on MQTT or an HTTP webhook: the subscription or field mapping carries a zone binding, which records the value as that zone’s own soil channel instead of merging it into the global humidity reading. See soil probes and zones.

Assignment happens in the zone’s settings: Settings > Zones > pick the zone > soil sensor. A reading you map by hand, an MQTT subscription or a webhook field, is bound to its zone on the source first and picked here second. One probe per zone. The picker lists every soil channel LocalSky has discovered: native gateway channels appear as source:<source_id>:soilmoisture<N>, a hand-mapped channel you bound to a zone appears as source:<source_id>:soilmoisture_<zone_slug>, and HA entities appear as ha:<entity_id>. The Sensors hub shows which zones each source feeds.

How the engine uses it:

  • Below the zone’s target band: the zone is eligible, and a measured-dry zone can override a soft forecast-rain skip. Run length is unchanged; it comes from the weekly water balance.
  • Inside the band: healthy; scheduled runs still apply unless the saturation threshold says otherwise.
  • At or above saturation: the zone skips on its own, even when the day’s verdict is Run, and the skip reason names the probe.

The Sensors hub and each zone’s detail show the probe’s live reading, the target band, and a 7-day no-watering projection so you can sanity check that the moisture curve actually behaves like your yard. If the probe goes offline, the zone simply loses its soil gate; nothing blocks, and run sizing is unaffected because the weekly water balance never reads a probe.

Worked example: a Home Assistant sensor feeding LocalSky

Say HA owns a Zigbee soil probe (sensor.back_yard_soil_moisture) and an outdoor thermometer (sensor.patio_temperature), and you want both in LocalSky.

Step 1: add the weather source in the UI. In Settings > Devices, add HA passthrough, enter the HA URL and a long-lived token, then use Field mappings to connect readings to the original HA sensor entities. A weather bridge does not require environment variables or hand-edited TOML. Create the token in HA under your profile > Security, and restart LocalSky after saving a new connection.

If you also use the legacy HA soil-entity picker described in step 3, its HA entity discovery uses the container’s HA_URL and HA_TOKEN (or HA_LONG_LIVED_TOKEN) environment variables:

# docker-compose.yml
environment:
  - HA_URL=http://10.0.0.10:8123
  - HA_TOKEN=${HA_LONG_LIVED_TOKEN}

Create the long-lived token in HA under your profile > Security.

Step 2: weather fields go through the HA passthrough source. The HA passthrough source (kind = "ha_passthrough") maps weather fields to HA entity ids via field_map and polls HA’s /api/states every 30 seconds:

[[sources]]
id = "ha_bridge"
priority = 50
enabled = true
kind = "ha_passthrough"
[sources.config]
base_url = "http://10.0.0.10:8123"
bearer_token = "${HA_LONG_LIVED_TOKEN}"
[sources.config.field_map]
air_temp_f = "sensor.patio_temperature"

Field-map keys are LocalSky weather field names (air_temp_f, rh_pct, wind_mph, rain_today_in, and so on); values are HA entity IDs. The example uses priority 50, matching the generic add-source form. Legacy environment synthesis uses 30 for HA; saved priorities remain configurable. Choose HA per reading in Settings > Devices > Which source provides each reading when it should be preferred. Numeric priority orders eligible sources; live observations and forecast fills remain separate tiers. Entities reporting unavailable or unknown supply no numeric sample and are not converted to zero.

The mapping picker also supports illuminance, lightning count and lightning distance. For Rain today, use a verified daily accumulated total. HA WeatherFlow’s precipitation sensor covers the preceding minute and cannot feed that field directly. Use another observed-rain source if no daily total exists. Do not map LocalSky’s own HA exports back into its inputs. HA passthrough reads current values every 30 seconds; keep a forecast provider enabled. See the WeatherFlow handoff guide.

Step 3: the soil probe is assigned per zone, not through field_map. Open Settings > Zones > Back Yard > soil sensor and pick the probe; it appears in the list as ha:sensor.back_yard_soil_moisture (the picker reads HA’s entity list using the credentials from step 1). From then on the probe gates that zone as described above.

Swapping hardware

Replacing a station or probe with a new unit? Edit the existing source entry (keep its id) instead of deleting it and adding a fresh one. Sensor history is keyed by source id and channel, and zone run history is keyed by zone slug, so an in-place edit keeps your charts, calibration context, and history continuous. Deleting a source and re-adding it under a new id starts those series over.

Empty states + progressive disclosure

The dashboard uses LocalSky’s <EmptyState/> UI primitive to render tiles for sensor data the operator hasn’t connected. Each empty state:

  1. Shows the kind of data that would go there
  2. Names what additional logic the data unlocks
  3. Links directly to the Devices page (/settings?section=devices) with hints for compatible sources

Example: the soil moisture panel renders as:

🌱 Add soil moisture data Per-zone moisture projection, yard-wide saturation skip, and visible dry-out detection light up when you connect a soil probe. Compatible sources: Ecowitt WH51, Aqara, HA passthrough. [Connect a sensor source →]

Once a source is providing the field, the tile shows its readings. A soil probe affects a zone only after you assign it to that zone. Zones without an assigned probe use weather and the soil model. Once assigned, a missing, stale, or untrusted probe holds its zone until reliable readings return; Force and a schedule’s weather-safety waiver cannot bypass that hold. Missing live weather is also a protected hold when no usable station or forecast fallback is available.

Hardware compatibility matrix

SensorDirect adapterVia HANotes
Tempest hub (UDP)Tested (v0.1)YesAir temp, humidity, wind, solar, lightning, rain, pressure
Ecowitt GW1100/GW2000 LANLive (v0.1)YesNative direct poll: /get_livedata_info for moisture/temp/EC/battery per channel, /get_cli_soilad for raw FDR AD used in calibration
Ecowitt WH51/WH52 (soil)Live (v0.1)YesPolled natively via gateway; LocalSky calibrates moisture per zone against dry/wet AD endpoints in its own config; battery-powered, 868/915 MHz
Aqara ZigbeeVia HAYesSoil moisture + temp probes; needs Zigbee coordinator
Sonoff ZigbeeVia HAYesSame as Aqara
Synoptic DataLive (v0.7.0)N/AA free token pulls the nearest real weather station’s measured wind, pressure, temperature, and humidity from a dense mesonet. Measured readings, but from the nearest station, which may be a few miles away
Ambient WeatherPlannedYesCloud API; socket.io
AcuRite tipping bucketVia Ecowitt or HAYes
PurpleAir / AirGradientDisplay onlyYesNo engine integration
OpenSprinkler flow sensorNativeYesRead via /jc water level field

Adding a new sensor source

Same shape as adding a weather source. See CONTRIBUTING.md in the repository root. The WeatherSource trait expects per-tick Observation { source_id, fields: Vec<(WeatherField, f64)> } events; soil moisture is just another WeatherField variant (SoilMoisturePct per zone, planned).

For sensors not in the WeatherField enum (e.g. flow meter readings, ambient pollen), the path is to extend the enum + add a Display-only tile to the dashboard.

“What if I have no sensors at all?”

You’ll get:

  • A working weather dashboard with forecast + radar
  • An engine that schedules irrigation from ET + soil + species + Kc math
  • A 7-day verdict strip
  • An LLM advisor (if configured) explaining decisions

You won’t get:

  • Soil saturation skip (with no probe there is no measured gate, so the weekly water balance decides alone)
  • Soil frost skip (covered by air-temp freeze rules)
  • Flow-validated runs (the engine trusts that the controller ran the requested duration)

That’s a fully usable setup. Sensors take it from “useful” to “trustworthy”; they’re additive, not gating.

Soil sensors

Wire a moisture probe to a zone and the engine gets a measured gate: a saturated zone skips on its own, and a measured-dry zone can override a soft forecast-rain skip.

Supported paths in:

  • Ecowitt soil probes (WH51 and friends) via a LAN gateway poll: native, no cloud, with moisture and battery for every probe. Soil temperature and conductivity come only from the newer EC probes; a WH51 reports neither.
  • Any Home Assistant soil sensor entity, through an HA bridge source.
  • MQTT topics and HTTP webhooks for DIY probes.

Assignment is one step or two, depending on how the reading gets in.

  • An Ecowitt gateway channel or a Home Assistant entity is ready to assign as soon as the source reports. Open Settings > Zones, pick the zone, and choose the probe in its Soil moisture sensor dropdown. One picker lists both kinds, so there is no separate Home Assistant list. The probe’s card under Settings > Sensors or Settings > Devices binds the same zone, if you are already looking at it.
  • An MQTT subscription, an HTTP webhook, or any other source whose readings you map by hand needs the zone bound on the source first. In the source editor, set that subscription’s or mapping’s Bind to zone. That control is on MQTT soil subscriptions, on HTTP webhook and REST poller field mappings, and on YoLink and Tuya device mappings. Prometheus and InfluxDB take the same binding as a zone_slug on the query in the source’s JSON, and Davis WLL and the Home Assistant passthrough take a soil_zone_map on the source config. The value is then recorded as that zone’s own soil channel instead of as a global reading, which is the whole point of the field: an unbound MQTT soil subscription publishes as humidity and is merged into the general humidity reading, and a webhook or polled-API mapping has no soil option at all until a zone is bound. Save, then open the zone and pick that channel as its Soil moisture sensor. Both halves are required; binding the source alone does not gate the zone.

A zone-bound channel appears in the zone’s dropdown only after the source has published at least once, because that list is built from the readings LocalSky has recorded. One probe per zone is structural: a zone holds a single soil sensor. One zone per probe holds only when you bind from the probe’s card under Settings > Sensors or Settings > Devices, which releases the probe from whatever zone had it. The zone editor writes the zone you are editing and nothing else, so picking the same probe there leaves the earlier zone’s binding in place. The Sensors hub shows which zones each source feeds, and the step-by-step walkthrough is in Add your first soil sensor.

How the engine uses it:

  • Below the zone’s target band: the zone is eligible. Run length is unchanged; it comes from the weekly water balance.
  • Inside the band: healthy; scheduled runs still apply unless the saturation threshold says otherwise.
  • At or above saturation: the zone skips on its own, even when the day’s verdict is Run, and the skip reason gives the measured percent and the saturation threshold it crossed.
  • A probe that goes offline is flagged as an anomaly on the irrigation and zones views, but only for a zone bound to a source: channel (an Ecowitt gateway, MQTT, a webhook, an entry in the Home Assistant passthrough’s soil_zone_map) whose last reading above zero is more than 24 hours old. A zone bound to a Home Assistant entity through the HA bridge is never flagged offline, because there is no local history to tell a flatline from a blip.
  • A probe that reads as a wild outlier versus its neighbors is flagged the same way, once three or more zones are reporting a reading and the zone sits further from the yard median than the outlier threshold, 35 percentage points by default. On a two-probe yard nothing is judged an outlier.

The Sensors hub and each zone’s detail show the probe’s live reading, the target band, and a 7-day no-watering projection so you can sanity check that the moisture curve actually behaves like your yard.

A probe also unlocks the tuning report’s two calibration checks: the drying-drift check (does your soil dry at the rate the configured texture and root depth predict?) and the sprinkler-rate backout (what rate do your heads actually deliver, per the probe’s rise across waterings?). Both need LocalSky’s own recorded probe history, so they work for a zone bound to a source: channel (the Ecowitt gateway poll, MQTT, webhooks, the Home Assistant passthrough’s soil_zone_map); a zone bound to a Home Assistant entity through the HA bridge has no local history and reports that state honestly.

Removing and disabling devices

LocalSky is meant to be the single place you manage your setup, so removing a sensor or a zone should not leave you hunting through a second app. This chapter covers what “remove” and “disable” actually do, and how far LocalSky can reach into the upstream device on your behalf.

Disable vs remove

  • Disable keeps the configuration but stops LocalSky using the thing. A source has an enabled flag; turning it off leaves the binding in place so you can turn it back on later. Nothing changes on the device.
  • Remove clears the binding entirely. For a soil probe, that means the zone stops depending on it, and the “soil probe offline” warning for that zone clears (a zone with no soil sensor simply waters on schedule and forecast).

Releasing Tempest for another application

Open Settings > Devices, expand the Tempest source, and turn it off or use Remove. Disabling keeps its configuration; removing also clears source selections and zone bindings that reference it. Neither action changes the hub.

The Tempest listener checks saved configuration every 15 seconds. Disabling or removing its source closes the UDP socket on that check, so another application can use port 50222 before LocalSky restarts. Complete any requested LocalSky restart to finish updating source connections and clear the watering hold. Adding a new source, including HA passthrough, needs that restart to start reading.

The hub broadcasts to the local network. Separate hosts can receive the same broadcasts independently; two applications sharing the same host network can conflict over the listening address and port. If HA’s WeatherFlow integration failed during that conflict, reload its integration entry after LocalSky releases its socket. You can then feed WeatherFlow readings through HA without changing Docker networking or editing TOML.

Removing a soil probe

Soil probes are managed wherever you see them:

  • Settings, Devices: the gateway or source that carries soil probes lists each probe on its card with a bind-to-zone selector and a Remove action right there. The card also links to the full soil-probe manager.
  • Sensors (the main sensors view): click a probe and its detail view carries a Remove probe action; the header’s Manage soil probes button opens the full manager.

The Remove action is the same everywhere. It always does three things safely on the LocalSky side:

  1. Clears the probe’s binding from whatever zone used it.
  2. Suppresses that zone’s offline warning (a removed probe is not a fault).
  3. Deletes the probe’s recorded readings, so it disappears from the device list and the sensor pickers instead of lingering until data retention ages it out.

If the probe lives on an Ecowitt gateway and you have set the gateway login on that source (see below), LocalSky can also unregister the sensor on the gateway in the same click, so it stops showing there too. The confirmation tells you exactly what will happen, and the result reports each side honestly: it will not claim a gateway removal that did not occur.

Why the gateway step matters

An Ecowitt gateway keeps a sensor’s registration, and its last signal and battery reading, effectively forever after the sensor goes quiet. Pulling the battery stops the live readings but does not remove the sensor from the gateway, so it keeps appearing in the Ecowitt app as if it were still there. The only ways to clear it are to delete it in the gateway’s own UI, or to let LocalSky do it for you.

LocalSky uses the gateway’s “disable this slot” state, which also stops the gateway from auto-adding the sensor back if it is still powered and broadcasting. So a removal from LocalSky is a real remove-and-stays-removed, not a temporary un-pair.

Enabling gateway removal

Gateway removal is off until you give LocalSky the gateway’s web login. The polling that reads your sensors does not need it (those endpoints are open on the LAN), so this is only for management writes. Add it to the Ecowitt source:

[[sources]]
id = "ecowitt_gw"
[sources.config]
host = "192.0.2.12"
username = "admin"
password = "your-gateway-password"

Without a login, the Remove button still clears the LocalSky binding; it just tells you to delete the sensor in the gateway UI yourself.

What each device class allows

Not every device can be managed from LocalSky, because they do not all expose a way to remove things. LocalSky is honest about this per device rather than pretending:

DeviceRemove from LocalSkyAlso remove upstream?
Ecowitt gateway sensorsYes (clears the binding)Yes, when the gateway login is set
Home Assistant entitiesYes (stops consuming)No, delete the entity in HA
MQTT sensorsYes (stops consuming)No, the publisher owns the topic
Cloud controller zones (Rachio, B-hyve, Hydrawise)Yes (clears the binding)No, zones are defined in the vendor app
OpenSprinkler stationsYes (clears the binding)Station disable is on the roadmap

Where LocalSky cannot reach the device, removing in LocalSky still does the useful half (stops using it, clears the warnings) and points you at the one manual step that remains.

Irrigation Controllers

LocalSky’s IrrigationController port abstracts the act of firing valves. The same engine output (zone X for Y seconds) dispatches to any supported controller. Pick the one that fits your hardware.

Supported controllers

ControllerPathCloud required?Scan zonesHardware cost (US$)Status
OpenSprinkler (boxed)Direct HTTP on LANNoYes130-180Shipped
OpenSprinkler PiDirect HTTP on LANNoYes~80 (Pi) + relay boardShipped
DIY / ESP32 (HTTP)Direct HTTP on LANNoYes5-40 ESP32 + valvesShipped
DIY / ESP32 (MQTT)MQTT (ESPHome, Tasmota, Z2M)NoNo (zone map by hand)5-40 ESP32 + valvesShipped
Home Assistant service callHA RESTNo (HA local)No (entity map by hand)Whatever HA drivesShipped
Rachio Gen 2/3Rachio cloud APIYesYes130-250Shipped
Hunter HydrawiseHydrawise cloud APIYesNo (relay id by hand)130-300Shipped
Orbit B-hyveB-hyve cloud APIYesNo (station number by hand)80-150Shipped
Rain BirdRain Bird cloud APIYesNo (station number by hand)100-300Shipped
DryRunNo-opNoYes (3 sample zones)NoneShipped

Prices are US retail; availability and cost vary by region. Rachio, B-hyve, Hydrawise, and Rain Bird are sold mostly through North American retail; OpenSprinkler and ESP32 hardware ship worldwide, which makes them the natural picks outside North America too. All four cloud controllers are offered in the controller picker as of 0.7.

The Scan zones column says whether LocalSky can ask the controller for its own zone list. Where it can, you never type an id: the zone editor’s Controller station field lists the controller’s zones by the controller’s name for each and stores its id, and a scan in the controller editor ends in a table where you say which of your zones each one fires. Where it cannot, you enter the id yourself, which is a relay id, a station number, or a Home Assistant entity_id depending on the controller.

Only four kinds can be asked: OpenSprinkler, Rachio, the DIY HTTP board, and the simulated controller. Hydrawise, B-hyve and Rain Bird have no zone-discovery endpoint, and Home Assistant, MQTT and ESPHome are not probed at all.

Rolling your own with an ESP32 or another relay board? See DIY & ESP32 controllers for the two supported paths (a small HTTP contract, or MQTT) with copy-and-flash reference firmware, beginner to advanced.

OpenSprinkler (the ideal)

OpenSprinkler is LocalSky’s reference controller for one reason: it speaks a documented HTTP API on the LAN with no cloud dependency. No telemetry to a vendor, no account required, no app subscription. The hardware is open-source (schematic + firmware) and the protocol has been stable for years.

Hardware options

  • OpenSprinkler 3.x boxed (24 stations, US$180), the canonical choice for an outdoor enclosure.
  • OpenSprinkler 3.x bare PCB (US$130), DIY mount.
  • OpenSprinkler Pi: a Pi HAT + relay board. Cheaper if you have a spare Pi.
  • OpenSprinkler OSPi-Plus: newer board, more I/O.

Firmware 2.1.9 or newer is required.

LocalSky integration

[[controllers]]
id = "os_main"
default = true
enabled = true
kind = "opensprinkler_direct"
[controllers.config]
host = "192.0.2.10"
port = 80
password_md5 = "<md5 of plaintext password>"
poll_interval_s = 10

The first-run wizard or Settings > Devices does this for you. The password_md5 is computed client-side at config time; the plaintext never leaves your browser.

What LocalSky uses

  • GET /jc for status (zone states, water level %, rain sensor, firmware version)
  • GET /cm for manual station start/stop
  • GET /cv for stop-all
  • GET /jl for run-history backfill

LocalSky never touches the program/schedule storage on the OS device. Schedules live in LocalSky’s engine; the controller is just a valve-firing API.

Where OpenSprinkler shines

  • Direct LAN control means no cloud lag, no service outages, no app required
  • Detailed status JSON (water level, rain sensor, flow meter, per-station runtime)
  • Native run-history endpoint enables LocalSky’s restart-recovery + audit
  • Active open-source community

Where OpenSprinkler falls short

  • HTTP only (no TLS by default; put it behind a reverse proxy if you must expose it)
  • MD5 password (legacy crypto; not a deal-breaker on a LAN but not great)
  • 24-station boxed limit (chain a slave for more)

Home Assistant service call (legacy continuity)

If you already drive irrigation through Home Assistant, OpenSprinkler integration, Irrigation Unlimited, Rachio HACS, ESPHome sprinkler, LocalSky can dispatch through HA service calls without replumbing anything.

[[controllers]]
id = "ha_main"
default = true
enabled = true
kind = "ha_service_call"
[controllers.config]
base_url = "http://homeassistant.local:8123"
bearer_token = "${HA_LONG_LIVED_TOKEN}"
start_service = "script.os_zone_toggle"
stop_service = "opensprinkler.stop"
[controllers.config.zone_entity_map]
back_yard = "switch.back_yard_zone"
front_yard = "switch.front_yard_zone"

LocalSky’s payload to HA is normalized: {"entity_id": "<from map>", "duration_s": <seconds>, "minutes": <float>}. Your HA-side script or service template picks the field it understands.

Use cases:

  • Migrating from an HA-driven irrigation setup without re-wiring schedules
  • Using a controller LocalSky has no native adapter for yet, when a Home Assistant integration for it already exists
  • Wanting irrigation runs to flow through HA’s automation engine for additional logic

ESP32 / DIY (ESPHome, Tasmota, custom)

An ESP32 with a relay board is a smart irrigation controller for ~$15-40 in parts. LocalSky drives it two ways, both first-class and both covered in detail on the DIY & ESP32 controllers page:

  • MQTT (mqtt_command): for boards that already speak MQTT (ESPHome, Tasmota, Zigbee2MQTT, a bare relay). Optional state/availability/flow readback. The bundled ESPHome reference firmware uses this path.
  • HTTP (http_generic): for a self-contained board with no broker. LocalSky polls a small REST contract, so Test connection + Scan zones work in the wizard. A copy-and-flash ESP32 Arduino sketch ships in examples/http/.

A native ESPHome protobuf adapter (esphome_native) is scaffolded but not yet built, so it is not offered in the controller picker. Use MQTT or HTTP above for ESPHome hardware today.

Cloud controllers (Rachio, Hydrawise, B-hyve, Rain Bird)

Four vendor controllers are driven natively through their own clouds; all ship in 0.7 and appear in the controller picker under “Cloud account”. Each authenticates with your vendor account (an API token, or account email + password) and maps LocalSky zone slugs to that controller’s zones/stations. Put secrets in env vars and interpolate them with ${...} so they never sit in the config in cleartext.

None of the four is ever found by a network scan. Scan my network, in the setup wizard’s controller step, sweeps every private /24 your machine has an interface on and asks each address for /jo on ports 8080 and 80; the only controller it recognizes in that answer is an OpenSprinkler. A cloud controller sits behind the vendor’s API rather than on your LAN, so nothing on the wire announces it and you add it by hand: pick the kind, paste the credentials, save. (The Scan network button on the Devices page is a different sweep, and it looks for weather gateways only.)

How a zone binds

A zone’s binding is the Controller station field on the zone itself: the controller’s own id for the valve that zone fires. Everything else is a way of filling it in.

  • Zone editor (Settings, then Zones): pick the controller’s zone from the list where the controller can be asked, or enter its id where it cannot. This is the field the binding lives in.
  • Controller editor (Settings, then Devices): Scan zones lists what the controller reports and gives each one a dropdown of your zones. Choosing there and pressing Bind zones writes the same field on each zone you pick. It binds existing zones and never creates one.
  • Setup wizard: Scan zones and import. Imported zones are created with the controller’s id already in Controller station.

Names on the two sides are free to differ. A Rachio zone called “Front Lawn” can fire a LocalSky zone called “Front Yard”, because what is stored is the id, not the name. LocalSky keeps the controller’s name alongside it as a label so the zone card can say which valve it is wired to, but nothing dispatches on that label.

A new binding is not live the moment you make it, but it is saved the moment you make it. Bind zones does the whole write in one press: it puts each chosen zone’s Controller station, the controller’s own name for that valve, and the controller id onto the zone, then saves the config to the server. Press it and navigate away and the binding is already persisted. Save controller changes is a separate press for the other half of the job, the controller entry’s own edits: its id, its enabled and default flags, and the Advanced JSON, including a zone_uuid_map a Rachio scan just filled. Those edits stay in the form until you press it, so the bind’s save does not carry them.

Neither press makes the binding live, because each controller’s zone-to-station map is built once, when the controller is constructed at start. So the bind reports that a restart is required and names that as the reason, and Settings > Devices shows a restart banner with a button that cycles LocalSky for you. The restart declines while a zone is watering unless you confirm it anyway, in which case the shut-off backstop closes the valves and boot reconciliation checks them again.

The controller’s own zone map, and why it is still there

Each cloud kind, Home Assistant and MQTT also hold a zone_*_map inside the controller’s config (zone_uuid_map, zone_relay_map, zone_station_map, zone_entity_map, zone_command_map). It is keyed by zone slug and is still read at dispatch as a fallback, so a config written before the picker existed keeps working untouched.

When both exist for the same zone, the zone’s own Controller station wins, provided its value is an id that controller kind understands. A value the kind’s parser rejects is ignored with a log line naming the zone, and whatever the map held stays in place. The parsers differ, and it matters: Rachio accepts only a zone UUID, so a station number there is ignored and the mapped UUID survives. Hydrawise, B-hyve and Rain Bird address zones by a NUMBER, so a number is accepted and replaces what their map held. Home Assistant accepts an entity_id and nothing else, so a leftover station number is ignored rather than sent to HA as a valve name.

On startup, LocalSky copies a map entry into any zone of that controller whose Controller station is empty and whose slug the map covers. That moves an existing binding onto the zone where you can see and change it, and it never overwrites a station you set. The map is left in place.

MQTT is the exception. An MQTT zone’s binding is a command topic plus its payloads, which is more than a single id can carry, so MQTT zones bind in zone_command_map only and Controller station is ignored for them.

You do not need Home Assistant for any of the cloud controllers; the native adapter talks to the vendor cloud directly. (Driving one through HA with ha_service_call is still an option if you already do that.)

Advanced JSON (the controller config escape hatch)

The controller editor’s Connection panel is a labeled form for scalar keys of whichever kind you picked, and the list differs by kind: host and port, base URL, tokens, a poll interval on OpenSprinkler and the DIY HTTP board. Below it, folded shut, is Advanced: raw config JSON, which is the whole config object as text. It is there for the keys the Connection form has no field for: the nested per-zone maps above (zone_command_map, zone_uuid_map, zone_relay_map, zone_station_map, zone_entity_map), and any scalar that kind’s panel leaves out, such as Rachio’s poll_interval_s. Home Assistant’s entity map is on that list for the same reason as the rest: the Connection panel for ha_service_call carries the URL, the token, and the start and stop services, and nothing else.

The two stay in sync both ways. Typing in a labeled field rewrites that one key in the JSON and leaves every other key structurally untouched, so a hand-written zone map survives an edit to the poll interval. Editing the JSON re-seeds the labeled fields. Neither surface owns the config; they are two views of the same object.

Scan zones is where they meet, and it is worth being exact about what lands where. On a Rachio, a scan writes what the cloud reported into zone_uuid_map in this box: slugs it reported are updated, slugs it did not report are left alone, so a hand edit survives a rescan. The scan also fills the bind table above, and the bind table is what actually binds, by writing each zone’s Controller station. Hydrawise, B-hyve and Rain Bird use the same kind of map but have no zone-discovery endpoint, so nothing scans into them and you enter their numbers yourself. On OpenSprinkler, the DIY HTTP board and DryRun a scan leaves this box untouched, because those kinds hold no zone map in their config at all; the result line says so, and the bind table still appears. Home Assistant and MQTT keep their maps here and cannot be scanned at all, and for MQTT this box is the only place its zones can be bound.

When a zone will not start

The error text now names which of these it is. Read it: it is the fastest answer, and it is what to paste into a bug report.

A station number on a Rachio zone. Rachio addresses zones by UUID, never by station number, so a number in Controller station is not an id it recognizes. It is now ignored and the scanned UUID is what dispatches, where it used to replace the scanned UUID silently and Rachio rejected every start. (On Hydrawise, B-hyve and Rain Bird a number IS the vendor’s id and still overrides the scanned map, so clear it there unless it is the vendor’s own number.)

The zone is not bound to anything. Either its Controller station is empty, or it holds an id this controller cannot use, and the controller’s zone map has no entry under its slug either, so there is nothing to fire. The zone card marks this one Unbound and the config check says so before you try to run it.

The second case is easy to reach and hard to spot: move a zone from one brand of controller to another and its old id stays in the field. A Rachio zone UUID means nothing to a Hydrawise, which addresses zones by relay number, and a station number means nothing to a Rachio. The check names the value and what that controller expects. (Switching a zone’s controller in the editor now clears the field for you; this catches configs written before that.)

The fix is one step: open the zone in Settings, then Zones, and set Controller station. Where the controller can be asked, the field lists its zones by name; where it cannot, enter the id (a relay id, a station number, or an entity_id). The error body lists what that controller can currently fire, so you can see exactly which of your zones is missing from it.

Do not rename the zone to make the two match. That was the old advice and it is wrong: a zone’s slug is permanent, because its run history, its soil sensor channel, its nine Home Assistant entity ids, its retained MQTT discovery topics and its /zones/<slug> links are all stored under it. Renaming trades a binding you can fix in one field for orphaned history you cannot. The slug field is read-only in the editor for that reason, and the raw TOML editor refuses a zone-key rename.

Clearing Controller station does not fix a mismatch either: an empty station is skipped, which leaves the zone exactly as unbound as before.

The daily API budget is spent. Cloud controllers cap how many requests an account may make per day, and LocalSky’s status polling shares that budget with the vendor’s own app and anything else on the same token. A rate-limited controller now says so, with the allowance its last response reported. Raising poll_interval_s spends fewer requests.

Whichever it is, the reason appears in three places: in the message on the button you pressed (the Zones page Run, or Test run on the zone card in Settings), in the container log at the moment of the attempt, and in the response body of POST /api/v1/irrigation/action if you are looking at the browser’s network tab. Include that text in a bug report; it carries the vendor’s own status and message.

A separate thing that is not a failure: after a run is accepted, the zone can keep reading idle for a while. A cloud controller reports its state on a throttle (Rachio: 60s at the fastest, 120s by default), which is longer than the window the Zones page waits before saying something. When that happens the message says the controller accepted the change and how often it reports state, rather than implying the run failed.

Rachio Gen 2/3

Uses a Rachio API token (Rachio app: Account Settings, “Get API key”). The device id can be left empty: the Test button resolves your account’s first device and offers to fill it in. Zones bind by picking them in the zone editor or in the controller editor’s bind table, so the TOML below is the end state, not something to type. The zone_uuid_map shown is the fallback described above; a zone bound in the editor carries its UUID in controller_station instead.

[[controllers]]
id = "rachio_main"
default = true
enabled = true
kind = "rachio"
[controllers.config]
api_token = "${RACHIO_API_TOKEN}"
device_id = "..."        # Rachio device id; the Test button can discover it
poll_interval_s = 120    # optional; 60..=3600, default 120
[controllers.config.zone_uuid_map]
back_yard = "..."        # Rachio zone UUID; filled by Scan zones

Facts to know about the Rachio path:

  • Rate limit. Rachio’s cloud allows roughly 1700 API requests per day per token. LocalSky polls live status at most every poll_interval_s seconds (default 120, floor 60; each poll is two API calls) and serves the cached snapshot between polls, which fits the budget with room for dispatch. The controller Test result shows the cloud’s remaining daily request count when Rachio reports it.
  • Stopping one zone stops the device. The public Rachio API has no per-zone stop; the only stop operation halts all watering on the device. LocalSky says so whenever it happens (the Stop button’s confirmation, the logs), and its bookkeeping matches: every run the stop ended is recorded at its real length. The shutoff backstop also verifies with the cloud before enforcing a deadline on Rachio: a zone that already closed on its own timer is released without any stop, and the widened enforcement grace absorbs normal cloud latency, so a multi-zone morning is never cut short by the previous zone’s deadline. Rachio’s own on-device timer remains the primary shutoff: a started zone always closes itself at the requested duration.
  • Run length cap. A single zone start is capped at 3 hours by the API; longer requests are clamped.
  • Live state. Running state and remaining time come from the cloud’s current-schedule endpoint, so runs LocalSky starts are observed and recorded in History like any other controller’s.
  • Deferred: webhooks and history backfill. Rachio supports push webhooks (exact start/stop events, rain-sensor state) but they need a URL the cloud can reach, which most LocalSky installs do not expose; polling is the default. Event-history backfill rides the same future webhook work, so the adapter reports no flow meter, no rain sensor, and no history query today rather than pretending.

Hunter Hydrawise

Uses a Hydrawise API key. controller_id scopes commands when your account has more than one controller; map each zone slug to its Hydrawise relay id.

[[controllers]]
id = "hydrawise_main"
default = true
enabled = true
kind = "hydrawise"
[controllers.config]
api_key = "${HYDRAWISE_API_KEY}"
controller_id = 0            # controller serial / id
[controllers.config.zone_relay_map]
back_yard = 1                # Hydrawise relay_id

Orbit B-hyve

Signs in with your B-hyve account email and password. device_id (from the account’s device list) scopes commands; map each zone slug to its B-hyve station number (1-based).

Like Rachio, B-hyve’s cloud has no per-station stop: stopping one zone halts all watering on the device, and LocalSky says so when it happens.

[[controllers]]
id = "bhyve_main"
default = true
enabled = true
kind = "bhyve"
[controllers.config]
email = "${BHYVE_EMAIL}"
password = "${BHYVE_PASSWORD}"
device_id = "..."            # from the account's /v1/devices list
[controllers.config.zone_station_map]
back_yard = 1                # B-hyve station number (1-based)

Rain Bird

Signs in with your Rain Bird account email and password. controller_id comes from your account’s controller list; map each zone slug to its Rain Bird station number (1-based). base_url defaults to the production endpoint and only needs setting if Rain Bird rotates hosts.

Like Rachio, the Rain Bird cloud has no per-station stop: stopping one zone halts the whole controller, and LocalSky says so when it happens.

[[controllers]]
id = "rainbird_main"
default = true
enabled = true
kind = "rainbird"
[controllers.config]
email = "${RAINBIRD_EMAIL}"
password = "${RAINBIRD_PASSWORD}"
controller_id = "..."                       # from the account's controller list
base_url = "https://rdz-rest.rainbird.com"  # default; override only if the host changes
[controllers.config.zone_station_map]
back_yard = 1                               # Rain Bird station number (1-based)

DryRun (no-op)

For testing, demos, and CI. DryRun records intent (with optional simulated runs that write to the SQLite history) but never fires anything.

[[controllers]]
id = "dry"
default = true
kind = "dry_run"
[controllers.config]
simulate_runs = true   # write fake completed runs into history for dashboard population

LOCALSKY_DEMO=1 env auto-creates this controller.

Multi-controller setups

The ControllerRegistry supports any number of controllers. Use cases:

  • Primary + backup: production OS device + DryRun for safety during config changes
  • Geographic split: front-yard OS + back-yard ESPHome on different LAN subnets
  • HA-bridged + direct: legacy HA-driven zones + new direct-controlled zones in the same deployment

Per-zone controller_id in ZoneConfig picks which controller fires that zone. Exactly one controller must have default = true; new zones inherit that.

Editing and renaming controllers

A controller’s id is a short slug you pick, like os_main or ha_backup. It is what a zone points at (the zone’s controller_id), and it is the only reference to a controller anywhere else in the config. The ID field normalizes what you type as you type it: capitals go lowercase and every character that is not an ASCII letter or digit becomes an underscore, so “OS Main” lands as os_main. Saving finishes the cleanup, collapsing runs of underscores and trimming them off both ends, so an id you type in the editor is stored as a clean snake_case slug.

Controller IDs are editable, even after zones are linked. When you rename a controller (in Settings > Devices), every zone that points to it migrates to the new id automatically, so there are no dangling references and no manual fixup. The default controller flag migrates the same way: change which controller is the default and new unassigned zones inherit it.

Two limits on that. A rename happens only when you actually edit the ID field, so saving an unrelated change on a controller whose stored id was hand-written and is not a slug leaves that id exactly as it was, rather than normalizing it behind your back and repointing every zone at an id you never asked for. And a rename onto an id another controller already holds is refused in the form before anything is written.

Run history is not rewritten. Its rows are keyed by the controller id that fired them and are an audit log rather than config, so runs recorded before the rename still name the old id.

Adding a new controller

Open src/controllers/<name>.rs, implement the IrrigationController trait:

#![allow(unused)]
fn main() {
#[async_trait]
impl IrrigationController for MyController {
    fn id(&self) -> &str { &self.id }
    fn supports(&self) -> ControllerCaps { ... }
    async fn run_zone(&self, slug: &str, duration_s: u32) -> ControllerResult<RunHandle> { ... }
    async fn stop_zone(&self, slug: &str) -> ControllerResult<()> { ... }
    async fn stop_all(&self) -> ControllerResult<()> { ... }
    async fn status(&self) -> ControllerResult<ControllerStatus> { ... }
    async fn run_history(&self, since_epoch: i64) -> ControllerResult<Vec<RunRecord>> { ... }
}
}

Add a variant to ControllerKind in src/config/schema.rs. Wire construction in src/runtime.rs::build_controllers. ~100-200 lines total.

See src/controllers/dry_run.rs for the minimal example, src/controllers/opensprinkler_direct.rs for a full HTTP-API integration.

DIY & ESP32 controllers

You do not need a boxed controller. If you have an ESP32 (or any board that can switch a relay and talk to your network), LocalSky can drive it as a first-class controller, same engine, same verdict, same dashboard. There are two supported paths; pick the one that fits how your board already works.

PathController kindBoard needsYou get back
HTTP / RESThttp_generica tiny HTTP serverfull status, zone discovery, wizard “test connection”
MQTTmqtt_commandan MQTT clientoptional state, availability, and flow readback

Both run entirely on your LAN, no cloud, no account. LocalSky always owns the watering decision and the run duration; the board just opens and closes valves.

Path 1: the HTTP contract (http_generic)

Implement these five endpoints on your board and LocalSky polls + commands it exactly like a boxed controller. An optional bearer token is sent as Authorization: Bearer <token> on every request when you set bearer_token.

Method & pathBodyPurpose
GET /status(none)current state (see shape below)
GET /zones(none)zone list for the wizard’s “scan zones”
POST /zone/{id}/run{"seconds": 600}start zone {id} for N seconds
POST /zone/{id}/stop(none)stop zone {id}
POST /stop_all(none)stop every zone

Success is any HTTP 2xx. Return 401 for a bad token. {id} is whatever string your board uses ("1", "back_yard", …); it’s what you put in each zone’s controller station field.

GET /status response (only zones is required; everything else is optional):

{
  "firmware": "1.0.0",
  "zones": [
    { "id": "1", "running": true, "remaining_s": 120 },
    { "id": "2", "running": false }
  ],
  "flow_gpm": 3.5,
  "rain": false
}

A board that includes flow_gpm is telling LocalSky a flow meter is wired in; omit it if you have none. GET /zones returns {"zones":[{"id":"1","name":"Back Yard"}]}.

LocalSky config:

[[controllers]]
id = "diy"
default = true
kind = "http_generic"

[controllers.config]
base_url = "http://192.0.2.50"
# bearer_token = "optional-shared-secret"
poll_interval_s = 10

Set each zone’s controller_id = "diy" and its controller_station to the board’s zone id. You do not have to look one up: the zone editor’s Controller station field lists what GET /zones reports, by name, and stores its id. In the setup wizard, Test connection hits GET /status and Scan zones imports GET /zones, just like OpenSprinkler.

Contract notes for firmware authors:

  • seconds is a positive integer. LocalSky caps a single run at 7200s (2h) before sending, but your board should enforce its own max-runtime watchdog too, so a lost network or server can never leave a valve open. The reference sketch in examples/http/ does this.
  • run, stop, and stop_all are POSTs. LocalSky sends a JSON body ({"seconds":N} for run, {} for stop / stop_all); accept and ignore an empty or {} body on stop.
  • Security: set bearer_token and check it on the board (constant-time compare if you can). On an untrusted segment, terminate TLS in front of the board; LocalSky pins the resolved IP and follows no redirects.
  • Forward-compatible: your board may include extra fields in /status (for example a contract_version); LocalSky ignores fields it doesn’t recognize.

Path 2: MQTT with state readback (mqtt_command)

If your board already speaks MQTT (ESPHome, Tasmota, Zigbee2MQTT, a bare relay), use mqtt_command. LocalSky publishes an on/off payload per zone, and LocalSky owns the shutoff timer. That alone is “fire-and-forget” control.

Add a state_topic per zone (and optionally a controller availability_topic and flow_topic) and the board’s reported state flows back into the dashboard, the HA-native MQTT convention most firmware already publishes:

[[controllers]]
id = "diy"
default = true
kind = "mqtt_command"

[controllers.config]
broker_host = "192.0.2.10"
availability_topic = "localsky-irrig/status"     # "online" / "offline" (LWT)
flow_topic = "localsky-irrig/sensor/flow_gpm/state"

[controllers.config.zone_command_map.back_yard]
topic       = "localsky-irrig/switch/zone_1/command"   # LocalSky -> board
state_topic = "localsky-irrig/switch/zone_1/state"     # board -> LocalSky
# on_payload / off_payload default to "ON" / "OFF"
# state_on_payload defaults to on_payload; matching is case-insensitive

Without a state_topic, LocalSky reports running state from its own run log. With it, the dashboard reflects what the board actually says.

A note on state payloads (plain vs JSON)

State readback compares the whole state_topic payload against state_on_payload (case-insensitive), and parses the whole flow_topic payload as a number. So point these at topics that publish a plain value, not JSON:

  • ESPHome publishes plain ON / OFF on its state topic, this works out of the box (it’s what the reference firmware in examples/esphome/ uses).
  • Tasmota publishes plain ON / OFF on stat/<device>/POWER, point state_topic there (not the JSON tele/<device>/STATE):
    [controllers.config.zone_command_map.back_yard]
    topic            = "cmnd/garage/POWER1"
    on_payload       = "1"
    off_payload      = "0"
    state_topic      = "stat/garage/POWER1"
    state_on_payload = "ON"
    
  • Zigbee2MQTT command works (publish to zigbee2mqtt/<name>/set), but its state is JSON ({"state":"ON"} on zigbee2mqtt/<name>), which the whole-payload match can’t read yet. Leave state_topic unset for Z2M relays, control still works; LocalSky just reports running state from its own run log.

Per-field JSON extraction for state topics is planned; until then use a plain state topic where one exists.

Reference firmware

Two copy-and-flash starting points ship in the repo, one per path:

  • MQTT path: examples/esphome/, an ESP32 relay board wired over MQTT with per-zone state, LWT availability, and an optional flow sensor. ESPHome speaks MQTT natively, so this is the smoothest beginner on-ramp. Edit the GPIO pins, drop in your Wi-Fi/MQTT secrets, and esphome run.
  • HTTP path: examples/http/, a single ESP32 Arduino sketch implementing the five-endpoint contract above (plus optional bearer auth). Flash it, point the http_generic controller at the board’s IP, and Test connection + Scan zones work end to end. The README there includes a curl script to exercise the contract from your laptop.

Pick MQTT if you already run a broker or ESPHome; pick HTTP if you want a self-contained board with no broker and the richest wizard experience.

Zones

A zone is one chunk of yard on one valve. LocalSky schedules each zone on its own: you describe the grass, the soil, and the area, and the engine computes the crop evapotranspiration (ETc), the weekly water balance, and the runtime from there. Select a zone on Zones and choose Edit zone to open its editor on that page. Settings → Zones uses the same editor. Cancel sits beside Save zone changes and asks before discarding edits. A failed save keeps the draft available to retry. Scalar changes apply on the next tick; changes to zone membership or controller bindings show a restart message when the server requires one.

The core fields

  • Name: what you call the zone (for example “Back Yard”). Change it whenever you like. It auto-derives an internal slug the first time, and that slug then stays put: see slugs are permanent below.
  • Grass species: picks the seasonal Kc curve, root depth, and MAD (allowed depletion) threshold. See the grass species catalog.
  • Soil texture: a USDA texture class (used worldwide). It drives field capacity, wilting point, and infiltration rate. See soil textures.
  • Area: approximate square footage. It does not have to be exact; it feeds leak detection and flow validation when a flow meter is present.
  • Controller and Controller station: which configured controller fires this zone, and which of that controller’s own zones it is. This second field is the binding. Where LocalSky can ask the controller for its zone list (OpenSprinkler, Rachio, a DIY HTTP board, the simulated controller) the field lists them by the controller’s name for each and stores its id, so you never copy an identifier by hand and the names on the two sides are free to differ. Where it cannot ask (Hydrawise, B-hyve, Rain Bird, Home Assistant) you enter the id: a relay id, a station number, or an entity id such as switch.back_yard_zone. MQTT is the one exception: an MQTT zone’s binding is a command topic plus its payloads, which lives in the controller’s zone_command_map, and this field is ignored for it.

A zone needs a controller before it can run; configure one under Settings, then Controllers, first.

A zone with no station and no entry in its controller’s zone map is Unbound: nothing will water it. So is a zone whose station holds an id this controller cannot use, which is easy to end up with by moving a zone from one brand of controller to another: a Rachio zone UUID means nothing to a Hydrawise. Either way the zone card marks it and the config check says which it is, rather than letting you find out the first night it does not run. See controllers when a zone will not start.

The slug is permanent

Every zone has an internal slug, derived once from the name you first gave it and shown read-only in Advanced options. It is not decoration. It is the key that stores:

  • this zone’s run history, and the trailing week of water the weekly budget allocator reads from it;
  • its auto / skip / run override, and its in-flight run ledger;
  • its dismissed tuning recommendations;
  • its soil sensor channel, as soilmoisture_<slug>;
  • its nine Home Assistant entities, whose ids are built from it;
  • its retained MQTT discovery topics, which have no way to be recalled;
  • its /zones/<slug> page, and every notification that ever linked there.

Changing it orphans all of that at once, silently, with no way back. So the slug field is read-only in the editor and the raw TOML editor refuses a zone-key rename. To change what a zone is CALLED, edit its Name; the slug stays as it is. Renaming a zone is also never the fix for a controller that will not fire it, whatever older versions of this guide said: bind it in Controller station instead.

Advanced options

The rest have sensible defaults, so a beginner can add a working zone with just the fields above:

  • Sprinkler type (rotor, spray, MP rotator, drip, bubbler): sets the default precipitation rate when the measured rate is blank.
  • Measured precip rate: a catch-cup measurement in mm/hr. Leave blank to use the catalog default for the sprinkler type; measuring it improves runtime accuracy substantially.
  • Max run time: the longest single watering the zone may run, in minutes. 60 unless you change it, and the field takes 5 to 360; every session is held to it. A save that raises the limit past 60 asks you to confirm first, so a stray keystroke cannot leave a valve open for six hours unattended. Only a raise past 60 asks: lowering it, or re-saving a zone already set to 90, stays quiet. Raising it does not switch off cycle-and-soak. When a zone’s heads put water down faster than its soil takes it, a run longer than one cycle is still split into cycles with soak gaps between them, and a longer session just means more of them. A run that fits inside one cycle applies in a single pass with no soak. See cycle-and-soak.
  • Weekly target and Sessions per week: the two numbers that size every run. The target is a gross depth in inches a week, rain included; the sessions are how many mornings it is split across, 1 to 7, spaced floor(7 / sessions) days apart. Leave either blank and LocalSky uses a default set by the zone’s species: its peak crop coefficient against reference turf, so warm-season turf starts at 1.00 inches over 2 sessions and established shrubs at 0.55 inches over 1. The box shows the default in effect, and the zone list marks a zone still watering on it. See the weekly water budget.
  • Soil moisture sensor (optional): assign a probe to drive this zone’s skip decision. The picker lists every discovered soil channel, both Home Assistant entities and LocalSky-native sources. Blank means the zone has no measured soil gate; it waters on the weekly water balance alone.
  • Healthy band low % and Saturation %: the zone’s soil thresholds. Below the low band the zone reads “dry” on the Sensors page; at or above the saturation percentage the zone skips watering.
  • Photo (optional): a picture of the zone, shown on its zone card. Drop an image onto the field or browse for one, and LocalSky uploads it, writes the file into its photos directory (/data/site/photos unless LOCALSKY_PHOTOS_DIR points elsewhere), and fills the field in with the URL it serves the file back at, /site/photos/<filename>. JPG, PNG, GIF, and WebP up to 10 MB are accepted; SVG is not, because an SVG can carry script. If the picture already lives somewhere else, paste its address into the URL box under the drop zone and nothing is uploaded. Uploaded photos sit outside the config, so they are not in the backup bundle; copy /data/site/photos yourself if they matter to you. See backup and restore.

Each zone card has a Test run button that fires the valve for 30 seconds, so you can confirm water actually comes out before trusting the overnight engine.

Forecast merge

LocalSky never trusts a single forecast. Configured forecast sources (Open-Meteo by default; NWS, MET Norway, OpenWeather, Pirate Weather optional) are merged by priority with per-field fallback, then bias-corrected against what your own station actually measured: if the model consistently runs 2 degrees hot over your yard in July, the merge learns that and compensates, per field, per calendar month.

The hourly canvas shows 48 hours of temperature, precipitation probability and amount, wind, and cloud cover. The 7-day row feeds the verdict strip. Forecast-aware skip rules read the same merged data, so the number you see is the number the engine acted on.

Sources are health-tracked: a polled model is “fresh” within its poll cadence (about 30 minutes for Open-Meteo) and the merge fails over to the next source when one goes quiet.

Choosing your forecast source

An install with no hardware uses Open-Meteo automatically (free, no API key), so you see a forecast immediately; it is the recommended zero-config pick. To drive the forecast pipeline with a different provider, open Settings > Devices and use the forecast source picker in the section titled Which source provides each reading. “Automatic” keeps Open-Meteo as the low-priority failover; selecting a provider (NWS, Pirate Weather, MET Norway, OpenWeather, or any enabled forecast-capable source) pins it to win regardless of the per-source priority ranking. If the pinned source goes offline the forecast still works by falling back to the next source, so a pin never blanks the forecast.

Data sources

Data sources decide which reading comes from where. When more than one source can report the same value, LocalSky picks a winner per reading, and this is where you steer that. Edit it under Settings, then Devices, in the section titled Which source provides each reading. Changes apply to the live engine on the next reading, with no restart.

Per-field priority and backup chain

Each headline reading (temperature, humidity, wind, rain, pressure, solar/UV) has an ordered chain of sources. The top source that is reporting now wins; if it goes quiet the next one takes over, so a reading is never lost.

  • A reading you have not touched shows the smart region default order, tagged Automatic.
  • Drag a row, or use the up/down arrows, to make your own order, tagged Custom. Reset to automatic drops your custom order.
  • Each row is badged with the honest nature of that source for that reading: your device and measured and radar measured are real measurements, real-time is a live analysis, and model forecast is a prediction. So the same cloud service can read “real-time” for temperature and “model forecast” for rain.
  • A live marker shows which link is reporting now, which are on standby, the backstop at the end of the chain, and any that are off.

No weather hardware? A cloud weather service can supply any reading’s current value, so the chain is where you decide which service backs up which, even with no local station.

Each source also carries one number, Default rank (advanced), on the Behavior panel of the source editor. You normally never touch it, because the chain above is the real control: for a reading you have put in your own order, that order is the priority.

The rank still decides two things. It sets the Automatic order for a reading you have not reordered, which is that reading’s sources sorted by rank, highest first, so the order on screen is the order LocalSky arbitrates by. And it is the backstop when every source in a custom chain has gone quiet: rather than blank the reading, LocalSky falls back to comparing ranks. There is one exception. While a stale chain’s last owner was a cloud service and some cloud is still reporting that reading, a local station is held off until the cloud tier is exhausted, so the reading does not flip tiers partway through an outage. That second case is why the number is worth leaving sane even if you never set one by hand.

The box is a whole-number field. The arrows and the browser’s own hint stay inside -100 to 200, but a value outside that range is accepted and saved, so treat it as a guide rather than a limit. Anything you add in the source editor starts at the schema’s default of 50 and stays there, a LAN station and a gateway you adopt from a network scan included. A cloud service is re-ranked once, on its first save, to the researched default for your region, so the regional authority sits above the keyless backstop without you arranging it. The per-region numbers are in the configuration reference.

Forecast source

A separate picker chooses which service drives the whole forecast: the daily and hourly outlook, the rain expected tomorrow, and the evapotranspiration estimate the engine waters from. “Auto (follow the chain)” keeps Open-Meteo (free, no key) as the low-priority failover; pick a provider to pin it to win regardless of ranking. A pinned source that goes offline still falls back, so a pin never blanks the forecast.

What lives elsewhere

Soil moisture is governed per zone, not as a per-reading chain, so it is bound in the zone editor via each zone’s soil sensor, not here. Sources whose data arrives on its own instead of being polled for (the Ecowitt LAN push receiver, an HTTP webhook, an MQTT subscription) go through the same source editor as every other kind, reached either from Add a data source on the Sensors page or from the Weather source button under Add a device in Settings, then Devices. The Sensors page shows a receiver’s readings the moment they land, so that is where you confirm the wiring worked. The underlying config keys these controls write (field_source_chains, field_source_overrides, and forecast_provider) are documented in the configuration reference.

Attribution

Installs using the Apple WeatherKit source display weather data provided by Apple Weather, and Apple’s terms require that attribution plus a link to their legal page wherever the data is shown. LocalSky carries the credit on the WeatherKit source card; the legal page is weatherkit.apple.com/legal-attribution.html.

Weather providers and what they measure

LocalSky pulls weather from two kinds of source: a local station sitting in your yard (or one you own, routed through a vendor cloud) and a cloud weather service that fills the gaps your hardware does not cover. They are not equal, and LocalSky never pretends they are. A real station that you own outranks every cloud service for the readings it actually covers; the cloud is there to fill the rest. This page lays the whole picture out as one wide table so you can read across a provider and see, field by field, exactly what its number really is.

Measured vs Nowcast vs Model vs Forecast

Every cell in the table below is one of a small set of honest words. The number on your dashboard might look the same whether it came from a gauge in your grass or a model grid 9 km away, so LocalSky labels its nature, not just its value:

  • Measured: a real instrument reading from a physical station. It can lag and it may not be your exact yard (an official station can be an airport miles away), but it is an actual observation, not a computed estimate.
  • Radar: a gauge-corrected radar rain estimate (NOAA MRMS). It is observation grade, not a model forecast: it measures the rain that actually fell on a 1 km cell over your block. The best off-yard rain read short of your own gauge.
  • Nowcast: a very-short-range analysis blending live radar and station reports (Pirate Weather in the US and Canada). Only seconds of lag, but it is a grid estimate, not a direct measurement.
  • Model: a model or ML estimate of the current conditions. Close to now, but computed, never a direct reading.
  • Forecast: a model or ML prediction, never a measurement. This is what every model provider’s rain really is, including Pirate’s: its rain is HRRR and GEFS model output, not radar, even when its temp and wind are a live nowcast.

The headline rule: local stations win for what they cover, and cloud services fill the gaps. A station measures your yard; a cloud service estimates it. When both are present, LocalSky takes the station for the fields it has and reaches for the cloud only where the station is silent.

Legend

WordMeaning
MeasuredReal instrument observation from a physical station
RadarGauge-corrected radar rain estimate (observation grade)
NowcastLive radar plus station analysis (seconds of lag, grid estimate)
ModelModel or ML estimate of current conditions (computed, not measured)
ForecastModel or ML prediction (never a measurement)
-The provider does not supply this reading

The full provider capability matrix

Rows are grouped: local stations first (a real station you own, the only sources that are Measured across the board), then the cloud weather services that fill in when you have no hardware for a given reading.

ProviderTempHumidityWindRain rateRain accumulationPressureSolarUVLightning
Tempest (local)MeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasured
Ecowitt (local)MeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasured
Ambient Weather (your station, cloud)MeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasuredMeasured-
Netatmo (your station, cloud)MeasuredMeasuredMeasuredMeasuredMeasuredMeasured---
La Crosse (your station, cloud)MeasuredMeasuredMeasured-Measured----
NWS (official station)MeasuredMeasuredMeasuredMeasured-Measured---
NOAA MRMS (radar rain)---RadarRadar----
Synoptic Data (nearest real station)MeasuredMeasuredMeasured--Measured---
Pirate WeatherNowcastNowcastNowcastForecast-Nowcast-Nowcast-
OpenWeatherModelModelModelForecast-Model-Model-
Apple WeatherKitModelModelModelForecast-Model-Model-
Open-MeteoModelModelModelForecastForecastModelModelModel-
Met.noModelModelModelForecast-Model---

Provider profiles: liveness, freshness, locality

The matrix above says what each provider covers. This table says what each provider is: how live its number is, how often it refreshes and for how long that number stays good, and how close to your yard it resolves. Every value here is joined straight from the code LocalSky runs, so the guide cannot drift from the app: the identity comes from the honest source catalog (src/sources/cloud_catalog.rs), the refresh cadence is each adapter’s poll interval, and the “good up to” window is the freshness ceiling from src/config/region.rs.

Rows follow the same order as the capability matrix (local stations first, then cloud), but the app presents providers by honesty rank (NWS, then your own cloud station, then radar, then the nowcast and model tiers) while irrigation decisions follow rain-trust rank: a real gauge and radar QPE outrank every model no matter how honestly it labels itself. Both orderings live in the catalog on purpose.

ProviderKeyLivenessRefreshesGood up toLocality
Tempest (local)noneLive LAN station60s600s (10min)Your exact yard
Ecowitt (local)noneLive LAN station30s (gw samples ~16s)600s (10min)Your exact yard
Davis (local)noneLive LAN station10s600s (10min)Your exact yard
Ambient Weather (your station, cloud)free keyReal station via cloud60s3900s (65min)Your exact yard
Netatmo (your station, cloud)free keyReal station via cloud10min3900s (65min)Your exact yard
La Crosse (your station, cloud)free keyReal station via cloud5min3900s (65min)Your exact yard
NWS (official station)noneOfficial observation, lags 30-90min30min2100s (35min)Nearest station, often an airport 5-30 miles away
NOAA MRMS (radar rain)noneRadar QPE (observation grade)3minRate 900s (15min), accum 7200s (2hr)1 km radar grid over your block
Synoptic Data (nearest real station)free keyReal station observation10min3900s (65min)Nearest station, can be several miles away
Pirate Weatherfree keySplit: live nowcast + model rain forecast10min3900s (65min)~3 km grid in the US
OpenWeatherpaidModel forecast10min3900s (65min)~500 m to 2 km cell
Apple WeatherKitpaidModel forecast10min3900s (65min)Tuned to your coordinates (most precise cloud)
Open-MeteononeModel forecast1-6 hr upstream2100s (35min)~2 to 13 km model grid
Met.nononeModel forecast, no live rain reading30min2100s (35min)~2.5 km Nordics, 9 km or more for a US yard

Two rows need a word beyond the cell:

  • Pirate Weather splits down the middle. Its temp, humidity, wind, pressure, and UV are a live Nowcast (live radar plus station reports, seconds of lag in the US), but its rain is a Forecast, HRRR and GEFS model output, not radar. Read its wind as live and its rain as a prediction, never the reverse. A free key sharpens the live temp and wind reads, and a real gauge still settles whether rain hit your yard.
  • Met.no ranks last for irrigation. It is the only provider that emits no live rain reading at all (emits_current_rain = false) and its probability-of-precipitation is synthesized (pop_is_synthetic = true), the only provider true for either. Its rain is not just a forecast, it is a fabricated probability with no live reading behind it.

How to read it

A few rows reward a second look:

  • The local stations (Tempest, Ecowitt) are Measured everywhere. Every cell is your own instrument. This is why a station you own outranks every cloud service for the fields it covers: no cloud cell on this table beats a Measured one. Ecowitt’s coverage is modular (the readings light up as you add the matching sensor), but the gateway is capable of every column.
  • The PWS rows (Ambient, Netatmo, La Crosse) are Measured too, just cloud routed. They are your own consumer station reached through the vendor cloud, so every field they report is a real on-site measurement, the same gauge a direct LAN hookup would read. They cover fewer columns than a Tempest because the hardware varies (Netatmo needs the add-on anemometer for wind and has no solar or UV; La Crosse is temp, humidity, wind, and a daily rain total).
  • NWS is a real Measured observation, but from the nearest official station, often an airport 5 to 30 miles away. It can simply miss the rain that fell on your yard. It reports a current rain rate but not a running daily total.
  • NOAA MRMS is rain only, and it is Radar, not a forecast. It measures the rain that actually fell on a 1 km cell over your block: the best off-yard rain read short of your own gauge. It supplies nothing else.
  • Synoptic Data is a real Measured observation, just possibly several miles away. It locates the nearest real observation station and supplies its measured wind, pressure, temperature, and humidity (Measured, but possibly several miles away, like NWS). It supplies no rain reading, and it needs a free token.
  • Pirate Weather splits. Its temp, humidity, wind, pressure, and UV are a live Nowcast (live radar plus station reports, seconds of lag in the US), but its rain is a Forecast, HRRR and GEFS model output, not radar. So the same Pirate row is honest-blue for wind and honest-amber for rain. A free key sharpens the live temp and wind reads in the US even though its rain is a forecast, and a real gauge still settles whether rain hit your yard.
  • OpenWeather, WeatherKit, Open-Meteo, and Met.no are model providers. Their current readings are Model (a computed estimate of now) and their rain is a Forecast (a prediction). Open-Meteo is the keyless backstop and is the only one of the four that also models solar and a daily rain total; Met.no is the coarsest for a US yard (a roughly 9 km grid) and synthesizes its rain probability rather than modeling it.

Lightning is local only

No cloud service on this table reports lightning. It comes only from a station with a strike sensor (Tempest’s hub or an Ecowitt WS6006). If lightning matters to you, that is a hardware reading, not something a cloud key can buy.

What this means for watering

LocalSky decides whether to skip a run on the most trustworthy rain signal it can find, in this order: a gauge on your own yard, then NOAA MRMS radar QPE, then an NWS station observation, then the nowcast and model providers. The table is why: a Measured or Radar rain cell is a fact about water that fell; a Forecast rain cell is a prediction that can report rain that did not fall or miss a small cell. LocalSky will use a forecast when it is all that is available, but it never labels one “live,” and a real gauge always settles the question.

For the merge mechanics behind this (priority, per-field fallback, and bias-correction against your own station), see Forecast sources and merge. For wiring the hardware that earns the Measured rows, see Weather and soil sensors.

Live radar

The Live Radar panel is a real weather map: animated precipitation, optional storm and lightning overlays, and a short-range precipitation forecast that extends the loop past “now”. It centers on your station location and works the same everywhere on Earth, because the imagery sources are chosen by region rather than hardcoded to one country.

This page covers what the radar shows, the providers behind it, how the region-aware default picks them, and how to take manual control.

What the radar shows

The map opens centered on your configured latitude and longitude. The base layer is animated precipitation: a loop of recent radar frames running through the present moment. A time label on the map names the frame you are looking at; the loop plays on its own and you can let it run.

On top of the precipitation loop, optional overlays add context:

  • Precipitation forecast: extends the animation into the future (see below).
  • Severe weather alerts (US): NWS active-alert polygons, colored by severity (red extreme, orange severe). Tap one for the headline.
  • Tropical cyclones: active storms worldwide (position, track, and forecast cone where the agency provides them). The label localizes to your region (hurricanes, typhoons, or cyclones).
  • Lightning strikes: recent strikes from your local station and, when enabled, the Blitzortung community network.
  • Wind flow: an animated particle field of current winds.

Every overlay degrades quietly: if a source is unreachable the rest of the map keeps working, and an overlay with nothing to show (a quiet storm basin, no active alerts) simply renders empty.

The Layers drawer

A Layers chip sits over the map (top right). Open it to see every available layer in two groups: imagery providers first, then feature overlays. Each row has an On/Off pill and an info expander with a short legend (color scale, refresh cadence, source). Toggle a layer on or off and the change is immediate.

Your toggles are remembered per browser. The first time you open the map it starts from the deployment’s default layers (set under Settings, Radar); after that, this device keeps whatever you last turned on. A toggle you made survives a layer temporarily leaving the menu (for example after a location change), so you do not lose your preferences.

The providers, and what each is good for

LocalSky draws imagery from public, key-free weather services. There are two kinds:

  • Animated radar + nowcast sources serve a rolling loop of frames and drive the time animation.
  • Reflectivity mosaics (WMS) are high-detail regional radar composites served as map tiles.
ProviderKindCoverageGood for
LibreWXRRadar + nowcastUS, Canada, Europe, Japan, Taiwan, SE AsiaThe regional default where covered: real radar plus a 60-minute nowcast
RainViewerRadarGlobalThe worldwide fallback: animated precipitation anywhere on Earth
IEM NEXRADReflectivity (WMS)US (CONUS)Sharp, street-scale US base reflectivity
NOAA MRMSRadar rainUS (CONUS)Observation-grade gauge-corrected radar rainfall; the best off-yard read of the rain that actually fell on your location
NOAA nowCOASTReflectivity (WMS)US incl. Alaska, Hawaii, Caribbean, GuamUS detail beyond the contiguous 48
Environment Canada GeoMetReflectivity (WMS)CanadaNational 1 km precip-rate composite
DWDReflectivity (WMS)Germany / Central EuropeRADOLAN precipitation composite
FMIReflectivity (WMS)FinlandNational dBZ composite

The two US reflectivity mosaics (IEM NEXRAD and nowCOAST) crossfade with the animated layer: when you zoom in, the high-resolution mosaic takes over for street-scale detail; when you zoom out, the animated loop dominates. You get the smooth animation at a glance and the sharp detail up close, with no manual switching.

Auto: the region-aware default

By default the provider menu is Auto. LocalSky reads your station location and offers global composites always, plus any regional source whose coverage includes you. Catalog order is preserved so the menu reads global first, then regional.

In practice:

  • Inside a LibreWXR region (US, Canada, Europe, Japan, Taiwan, SE Asia): LibreWXR leads as the default radar, with RainViewer kept as the global fallback, and your country’s reflectivity mosaic added when one exists.
  • Outside the LibreWXR regions (for example Australia): RainViewer is the default radar, since it covers the whole planet.
  • Border areas get both neighboring national composites on purpose (a Toronto user sees both the Canadian GeoMet layer and nearby US NEXRAD), because radars near the line still paint useful returns across it.

You do not have to configure anything for this to work. Auto follows wherever your station is.

Custom: choosing your own providers

To override the regional default, go to Settings, Radar and switch the provider menu from Auto to Custom. The list seeds from whatever Auto currently resolves to, so you start by editing the recommendation rather than a blank slate.

In Custom mode every catalog provider has an On/Off pill, and a Recommended badge marks the ones Auto would have picked for your region. Any provider is allowed anywhere: this is deliberate, so you can keep, say, a US reflectivity layer enabled in Europe to compare how two sources render the same system. The coverage label tells you where a source actually paints tiles; nothing stops you from enabling one out of its region.

Two notes:

  • A Custom menu must have at least one provider enabled. An empty Custom list would round-trip as Auto, so Save is blocked until you enable one.
  • The stored list always keeps catalog order regardless of the order you clicked, so your saved configuration stays stable across edits.

Default layers

The same Settings, Radar page has a Default layers section: the layers (providers and feature overlays) that start visible for a browser with no saved preference. This sets the first-load experience; once a device has toggled layers on the map, those per-browser choices win. A default for a provider you removed from the menu is simply ignored, so leaving extras lit is harmless.

The precipitation forecast layer

The Precipitation forecast overlay extends the radar loop into the future. When you scrub or let the animation play past “now”, it keeps going into forecast frames, each clearly tagged “+Nm forecast” so a prediction is never mistaken for an observation.

Where the radar source supplies a real nowcast (LibreWXR), those native radar frames carry the forecast out to about an hour. Everywhere else the forecast is an Open-Meteo model precipitation grid sampled over the visible map and drawn as an animated heatmap for the next couple of hours. It is lazy: nothing is fetched until you turn the layer on, and it refetches as you pan.

Attribution

Every provider and overlay carries its source attribution in the map controls and in the Layers drawer expander. Some sources require it: the Blitzortung lightning credit (CC BY-SA 4.0) is shown whenever community strikes appear, and the WMS composites name their issuing agency. The attribution line on the map adapts to whichever sources are actually contributing at the moment.

Where to read more

7-day verdict strip

The row of day cards at the top of the Irrigation tab. Each card is the engine’s answer to one question: “if this day were tonight, would we water?” computed against the merged forecast for that day.

Each card shows the day’s weather glyph, the high/low, expected rain, and a verdict pill:

VerdictMeaning
RunConditions clear every skip rule; zones water their planned minutes.
SkipA rule trips (rain, wind, cold, soil already wet); the reason is on the card.
ExtendA heat trigger lengthens runs beyond the baseline plan.
OffWatering is paused (vacation mode) or outside allowed days.

Only tonight’s card is a commitment; later days re-evaluate every forecast refresh, so a Tuesday “skip” can become “run” as the rain chance fades. The strip exists to answer “do I need to think about watering this week?” at a glance.

The same verdict logic powers per-zone pills on the Zones page; a zone can disagree with the day (its own soil probe says wet) and skip alone.

Morning advisory

The sentence at the top of the Irrigation tab that explains today in plain words: what’s running, what’s skipping, and the one reason that matters.

It’s assembled from the engine’s actual decision (never a guess), and when the optional AI advisor is configured it gets a more natural voice; without one, a deterministic template produces the same facts.

The advisory updates whenever the decision does: forecast refreshes, threshold changes, manual runs, and probe readings can all change tonight’s plan, and the sentence follows.

Skip rules and thresholds

The engine checks every planned run against a short list of vetoes, in order. First trip wins; the reason is recorded and shown.

RuleDefaultWhat it protects
Rain in the recent window0.20 in (5 mm)Don’t water what the sky watered.
Observed rain recently~0.25 in over today plus the past dayMeasured rain skips on its own, independent of any soil reading.
Rain expected in the next hoursforecast x probabilityDon’t water ahead of a storm.
Wind10 mph (16 km/h)Spray pattern integrity (drift loss).
Freeze / low temperature38 F (3.3 C)Ice on hardscape, plant shock.
Soil moisture (per zone, with a probe)zone target bandThe probe outranks the model.
Allowed days / restrictionslocal rulesWater-authority schedules, municipal restrictions, HOA rules.
Vacation pause / dry-runmanualYou said so.

Thresholds are tunable in Settings under Logic (and live-tunable from the Irrigation tab’s threshold sliders). The History tab’s “Why it skipped” panel aggregates which rules actually fired over the window, so you can see whether a threshold is doing real work or just noise. The tuning report goes one step further for the rain rules: its forecast-skip scorecard tells you how often the rain a skip was betting on actually arrived.

Not every skip is final. A soft forecast-rain veto can be demoted back to a run when the zone is measured dry: the soil floor moat lets a trustworthy dry reading override rain the sky only promised but has not delivered. Measured rain (the observed-rain backstop above) is not soft and is never demoted this way.

A bad or offline soil probe cannot block or force a run on its own. When a probe looks untrustworthy, its value is inferred from its trustworthy neighbors (quarantine), so one flaky sensor never vetoes a zone or falsely triggers one.

Heat advisory is the one rule that extends instead of vetoes: when the forecast high crosses its threshold, planned runs stretch by the configured multiplier.

Tuning report

Zone settings are guesses on day one: soil texture picked from a chart, a sprinkler rate from a catalog, a watering budget from a rule of thumb. The tuning report watches what actually happened over the last two weeks (runs, skips, rain, and your soil probes when you have them) and turns it into at most one plain suggestion per zone, each with an Apply button and the evidence behind it.

Everything in the report is deterministic arithmetic over your own recorded outcomes. No AI is involved, and nothing changes until you apply a suggestion yourself.

Where it shows up

  • Each zone’s detail page has a Tuning panel below the watered-minutes chart.
  • The irrigation page shows a one-line strip when any zone has a suggestion, plus the forecast-skip scorecard described below.
  • If notifications are enabled, LocalSky sends at most one “tuning report ready” notice per week, and only when there is something to apply.

What it checks

Sessions shorted by the run limit. Every zone has a limit on a single run (60 minutes unless you set one). When the engine chronically wants more minutes than the limit allows, each session quietly delivers less water than the model asked for. The first suggestion is to raise this zone’s run limit so each session delivers in full; the report only offers it up to 360 minutes and, when it can compute the morning schedule, only when the longer sequence still finishes before sunrise. When the raise cannot work, the report falls back to splitting the week’s water across more sessions, or, when even daily capped sessions cannot deliver the configured weekly target, aligning the target with what the system can actually deliver. On a zone the soil model governs, the run being trimmed is the one-shot refill of the zone’s soil deficit. When the per-run duration cap is the clamp, the run-limit raise is the whole suggestion chain there: session count and weekly target never shape a refill, so neither fallback is offered. When an explicitly set weekly target is the clamp instead (under the soil model a set target acts as a rolling 7-day delivery ceiling), the report suggests raising that target to the soil model’s weekly demand estimate, or clearing it; an inferred target never caps and never draws this suggestion. While an active watering restriction caps runs below the zone’s own limit, run-length suggestions pause and the report names the restriction’s cap instead.

A water bucket that cannot be right. From your soil texture, root depth, and the week’s forecast demand, the engine computes how many days one filling of the root zone should last. A bucket that lasts more than three weeks points at a gross misconfiguration and draws a one-step suggestion: a drier texture, or restoring the species-default root depth when an override explains it. A small bucket only draws a suggestion when a root-depth override explains it. Sand and other fast-draining soils legitimately compute a day or less of demand, the engine tops them up daily, and texture now sets how much storm rain counts against the weekly target, so state your real soil and the report leaves it alone.

Drying drift (needs a soil probe). During stretches of two days or more with no watering and no rain, your probe’s drying rate is compared against the rate the configured bucket predicts. A probe that dries much faster than the model says your soil holds less water than configured; much slower says it holds more. The suggestion is always one step: the adjacent soil texture, or restoring the species-default root depth when an override explains the mismatch. Only drying rates are compared, never absolute probe percentages, because probe scales vary by calibration.

The sprinkler’s real rate (needs a soil probe). Each watering makes the probe rise. From the rise across your recent waterings and the valve-open time, the report backs out the precipitation rate your sprinklers actually deliver. When that measured rate disagrees with the configured one by more than 30 percent, the suggestion is to set the measured rate, the same correction a catch-cup test would give you without the cups.

The water-balance lines

Each zone’s panel states the three terms the weekly water balance settled against, one plain line per term with its source:

  • Observed rain over the last 7 days, named by the rung that supplied it: your gauge or radar day totals whenever any exist (a measured dry week reads 0.00 in from the gauge and is never overridden by a wetter regional model), the forecast provider’s model archive when no measured day exists, and on installs with neither, the balance runs on the corrected forecast alone. On US installs without gauge or radar day totals, one informational line notes that a rain source reporting day totals unlocks the observed-rain credit (NOAA MRMS day-total products qualify).
  • Irrigation applied over the last 7 days, from the run history, with the sessions remaining this week.
  • Forecast credit until the zone’s next session, with the bias correction applied, or the plain reason there is none (the next session is due now, or the bias model has too few rain days this month to correct anything).

Every zone the weekly plan governs also carries a soil model comparison line: what the soil model would have watered this morning against what the weekly plan did. It is the running preview of the selectable soil model, on your own yard’s numbers. A zone the soil model governs does not carry the line, because the soil plan is the plan there.

When a suggestion fires, its evidence includes the full numeric breakdown (target, rain, applied, credit, remainder) so the numbers behind it are auditable in place.

Snooze and dismiss

Next to Apply, every suggestion offers Snooze 30 days and a quieter Do not suggest this again. Snooze silences this exact suggestion; if the evidence later points at a different value, the new suggestion appears immediately, and otherwise the original may return after 30 days. Dismissing is permanent for that setting on that zone: the suggestion stays gone even as its numbers drift.

Silencing is complete for that suggestion: it drops out of the zone cards, the counts, the irrigation-page strip, and the weekly notification (a week whose every suggestion is silenced sends nothing). It never hides the rest: when another check has something worth suggesting, that suggestion takes the silenced one’s place. The zone’s panel keeps one muted line noting the silencing, with an Undo that restores the suggestion on the next report.

The forecast-skip scorecard

One line for the whole installation: of the days LocalSky skipped watering because rain was expected, how often did the rain actually come? Each skip is judged against the window it claimed: a “rain expected within 4 hours” skip against that day’s total, a “tomorrow rain” skip against the next day’s, a three-day forecast against the following three days. The line appears once at least three skip days could be scored; until then it says so.

Skips for rain that was already falling, or already on the ground, are not forecast calls: they confirm themselves, so grading them would only flatter (or unfairly ding) the forecast. They get their own plain count instead, with no scoring.

When there is not enough data

Every check states exactly what it is missing rather than guessing: not enough completed runs, not enough qualifying dry stretches, too few probe readings, no clean watering events with a probe response. A zone without a soil probe shows which checks a probe would unlock. Zones bound to a live Home Assistant soil entity get the same plain statement: LocalSky keeps no local history for those bindings, so the probe checks are unavailable.

What Apply writes

Apply writes exactly the configuration field the suggestion names (and, for a measured sprinkler rate, marks the rate as measured), through the same validated path as the settings editor: the change is checked, saved, and picked up by the engine on its next evaluation. Every apply snapshots the previous configuration first, so Settings and the backup page can roll it back like any other edit. If the report’s data has moved since the page loaded, Apply refuses with a plain message instead of writing a stale value; refresh the report and look again.

Applying a run-limit suggestion above 60 minutes asks for the same confirmation the zone editor uses, and once the save lands a notice goes to every device with notifications enabled. The save is never blocked, and an active watering restriction’s own per-zone cap still wins over any raised limit.

Reading further

Why this duration?

Every zone’s detail view shows the inputs behind tonight’s planned minutes, because “trust me” is not a number.

Where the depth comes from. Tonight’s run length is decided by the zone’s governing model. Under the default weekly water balance: a gross weekly target per zone, settled against observed rain, water already applied, and a probability-weighted forecast credit, with the remainder split across the sessions still expected this week. Under the soil model: the zone’s replayed soil deficit divided by the capture efficiency and the throughput, capped at the run limit. Those pages hold the arithmetic that produced the minutes.

The panel is in two parts, because only two of its numbers reach the dispatch.

What sizes tonight’s run

  1. Throughput (mm/hr): how fast your sprinklers actually apply water, either measured (catch cups) or the catalog default for the head type. This week’s session depth divided by this rate is where the run length starts. It then takes the seasonal adjustment and any condition rule’s multiplier, and is held to the zone’s cap, so the minutes on the panel do not have to equal depth divided by throughput.

    That seasonal adjustment is one dial for the whole yard, on the Engine settings page: 100% is the depth as computed, and you turn it down for a wet, cool stretch or up for a heat wave. It scales the depth before the zone’s cap clamps the result, so the minutes here already include it; the irrigation engine page carries the dial’s range and the rest of its behavior.

  2. Scheduled: the minutes that will actually dispatch, and what the zone’s safety ceiling did to them. The ceiling is max_run_minutes, tightened further by any active watering restriction. The row reads “capped at N min” when tonight’s run is sitting on the ceiling because something asked for more than it allows: the weekly balance’s own session, the seasonal adjustment, or a condition rule’s multiplier. The zone is being shorted, so raise the ceiling, or raise sessions_per_week so each session is shorter.

    A zone with no run planned shows its minutes and nothing about a cap. There is no run for a ceiling to have shortened, and the reason the zone is not watering is on the zone card. A zone’s weekly session can outgrow the ceiling while today’s plan is zero for a separate reason (spacing, a rain defer, an Override schedule); the tuning report is where that shows up, because it is a statement about the week rather than about tonight.

    A Force override is the one case where minutes appear with no target behind them. Set Force on the zone, or globally with the zone left on Auto, and a run whose computed length came out zero waters a bounded default of five minutes instead, held down to the zone’s ceiling when that ceiling is shorter. Without the floor, a Force on an already satisfied yard flips the verdict to run and then dispatches nothing, because a zone planned for zero seconds is skipped. A zone sitting at zero for any other reason stays at zero, and so does a day an Override manual schedule already covers, so a forced run never stacks on top of the run you scheduled yourself.

Not part of tonight’s minutes

On a weekly-governed zone none of these four scales the run; they feed the ETc figure and the soil projection. On a soil-governed zone the panel moves the soil deficit and the capture efficiency above the line, because there the run length IS the deficit divided by the capture efficiency and the throughput.

  1. Soil deficit (mm): how far the zone’s soil sits below full, negative when the zone needs water. The soil model’s replay of measured ET, rain and completed runs computes it for every zone with a species and a soil texture, whichever model governs; a dash appears only where no bucket can be derived (env-var zone lists). Printing a 0.00 there used to tell people their soil was full on the strength of a number nothing measured.
  2. Crop coefficient (Kc): the species’ seasonal multiplier on reference ET (see the grass species catalog). Hemisphere-aware: south of the equator the curve shifts six months.
  3. Heat multiplier: optional extension when the peak heat index crosses the heat-advisory threshold. Each day’s heat index pairs that day’s high temperature with that same day’s humidity (not the current, often night-time, humidity), so a cool morning’s humidity is never combined with a hot afternoon’s peak to inflate the run.
  4. Capture efficiency: how much of the applied water lands in the root zone (wind drift, overspray, runoff losses). Weekly-governed zones show the fixed 0.70 the soil projection uses; soil-governed zones show the configured engine.capture_efficiency, the number each refill divides by.

The panel prints no formula. It used to print one belonging to that Home Assistant integration, which matched nothing LocalSky computes, and which multiplied the four numbers above as though they set the run length.

Weekly water budget

LocalSky tracks how much water each zone has received over the trailing week, from every counted source, against what the engine thinks the week should deliver. Rain counts per day, and each day is capped at what the zone’s root zone can hold: rain beyond that in a single day drains past the roots and never reaches the plant, so it does not count against the week.

There is no budget screen yet. What you see of it in the app is the hold line on a zone card and on zone detail, which names the gate that zeroed the zone for the day. The full per-zone rows (target, applied, observed rain, forecast credit, remainder, remaining sessions) are on the API at /api/v1/irrigation/snapshot under water_budgets.

Counted in:

  • Irrigation runs (recorded per zone, per second of runtime, converted through the zone’s precipitation rate). Applied water counts in full; the per-day cap below is a rain rule only.
  • Measured rainfall (from your station or gateway), credited per day: each day counts up to the zone’s per-day cap, which is the root zone’s own capacity (field capacity minus wilting point, times root depth) unless you set Rain the soil can bank per day in the zone editor. The forecast credit takes the same per-day cap.

The target is a flat weekly depth per zone, in inches including rain. It comes from the zone’s weekly_budget_in setting, or, when you have not set one, from a default set by the species you declared on the zone: its peak crop coefficient against a reference peak of 1.00, so warm-season turf starts at 0.85 in over 2 sessions and established shrubs at 0.55 in over 1. A zone with no zone config declares no species, so its default comes from the zone’s name instead: 0.50 in over 1 session when the slug carries shrub, garden or bed, and 1.00 in over 2 otherwise. The target does not move with the season, and nothing recomputes it from ET0 or the species coefficient. ET0, Kc and ETc are computed and displayed, and the tuning report will suggest a different weekly_budget_in when the zone cannot deliver the one it has, but the target itself changes only when you change it.

Sandy soil

The per-day cap is why a sandy yard no longer skips a whole week after one storm. Sand at default turf roots (150 mm) holds about 0.35 inches; a 1.2 inch storm day credits 0.35 inches and the rest drains past the roots, so the balance resumes watering mid-week instead of counting water the lawn never kept. A loam yard holds 0.89 inches a day and only notices the cap in storms bigger than that.

Derived caps at default turf roots (150 mm), per soil texture:

TextureCap (mm/day)Cap (in/day)
Sand9.00.35
Loamy sand12.00.47
Sandy loam19.50.77
Loam22.50.89
Silt loam25.51.00
Clay loam24.00.94
Clay21.00.83

A deeper root depth (a species default or the zone’s own override) raises the cap proportionally. Set Rain the soil can bank per day in the zone editor to override the derived value; the field’s placeholder shows the number in effect. The cap does not decay older rain by ET: the weekly target already accounts for typical ET, and decaying the credit would count it twice.

When a zone looks off plan: persistently dry means runs are being skipped or are too short, and the zone’s “Why this duration?” panel shows the throughput and whether the run hit its cap. Persistently soggy means rain is doing the work and the engine should be skipping more, or the precipitation rate is set too low.

This budget is what decides watering for weekly-governed zones. Its per-zone remainder sizes each session, and when the remainder reaches zero, or forecast rain is imminent, or the session spacing has not elapsed, the zone plans zero seconds for the day and says which of those it was. A zone in that state reads ON HOLD on the zone card and detail with the reason beside it.

The session length that remainder buys is not the last word. The seasonal dial, engine.seasonal_adjust_pct, scales it before the zone’s maximum run time clamps the result, so the planned minutes you see already carry the dial. At 100% you get the depth this ledger computed, and you turn the dial down for a wet stretch or up for a heat wave instead of re-editing every weekly target; the full treatment is under the seasonal water budget.

The soil model is the other scheduling model, and the shipped default: a zone it governs waters when its own soil deficit crosses the trigger and each run refills the deficit, so both the trigger and the size come from the soil instead of this weekly ledger. engine.scheduling_model picks the default and the zone editor pins it per zone, so a weekly-governed zone is one you pinned to weekly, one on an install whose engine.scheduling_model is weekly that carries no per-zone pin, or one with no zone config at all: an env-var install, or a controller zone you never configured in LocalSky. That last kind has no texture and no species to size a bucket from, so it stays on this ledger whatever the engine default says. Under the soil model, a Weekly target you set by hand stays honored as a rolling-7-day delivery ceiling, and Sessions per week stops steering because cadence follows soil texture and roots. The soil deficit computes for a zone that has a zone config, whichever model governs it, once its trailing window carries at least three evidenced days (a resolved ET0 day, a rain day, or a completed run); below that the zone publishes no soil fields and the weekly ledger sizes it, which is where a fresh install starts. A zone with no zone config gets no soil fields at all, and no zone gets them on a tick where the runs read failed. The zone detail’s Soil model block shows what it plans.

History

The Run log leads with recorded activity. Choose a range or month and search by zone or reason. Sessions are grouped by their start date in your installation’s timezone; expand a session to inspect its cycles and original records. Manual watering and the normal automatic schedule retain their sources.

Watering insights has a separate, clearly labeled 30-day, 90-day, or one-year window:

  • Watering time counts valve-open minutes, excluding soak waits and duplicate controller observations. It is duration, not measured volume.
  • Watering sessions counts recorded watering events.
  • Skipped zone mornings and Why scheduled zones held use recorded automatic outcomes, once per zone and local day. A later watering outcome replaces an earlier hold. A changing live forecast is not a completed skip.
  • Daily trends, the calendar, and the per-zone split show where watering occurred. An empty day means no watering was recorded; it does not prove a skip.

Rain forecast review is collapsed below the watering insights. Expand it to compare completed-day forecasts with gauge evidence. This feedback does not measure water saved. Incomplete days and unavailable observations are not scored.

Print creates a report; Download CSV exports the stored records. History lives in LocalSky’s own SQLite database. A failed history request is reported as unavailable, never as zero watering.

Today’s run and tomorrow’s projection

The Irrigation page separates Today · normal irrigation run from Tomorrow · projected. Today’s result comes from stored automatic-run rows, including the reasons recorded at dispatch. When those rows are absent, LocalSky says there is no automatic-run record rather than guessing from today’s weather. Manual runs remain visible in the run log.

Tomorrow uses tomorrow’s forecast verdict. The morning check time includes its day and timezone. Normally LocalSky works backward from sunrise minus 15 minutes by the sequence’s watering and soak duration. A zero-minute plan checks 15 minutes before sunrise; freeze forecasts can move watering to a safe post-sunrise window. The time can move as the plan changes. Fresh evidence and the applicable rules determine the final decision at the check.

Notifications

LocalSky notifies you about the things that matter to a lawn and a water bill:

  • Zone started and zone stopped, with the duration.
  • Daily verdict once per day, the first time the morning’s decision is made (skip, run, run extended, with the reason).
  • A zone that did not start because the controller refused the command, and a controller that is not answering when the morning needed it.
  • A valve that may still be open: its shutoff was due and the controller has not confirmed closing it. LocalSky keeps retrying; this is the one notification worth walking outside for.
  • Water moving with nothing running, when a flow meter is connected.
  • A weather source that went quiet, and a soil probe that stopped reporting.

Three channels deliver them: Web Push to a subscribed browser or the installed app, ntfy to any topic on any ntfy server, and Slack through an incoming webhook. Enable any or all under Settings, then Notifications; the wizard asks for the ntfy and Slack URLs on a new install. The Home Assistant MQTT block on the same page is a different feature, the discovery publisher for entities and sensor states; see the HACS integration page. The dashboard-only nudges (a tuning report is ready, a run cap was raised) go to Web Push alone.

ntfy and Slack

ntfy wants a server (the public https://ntfy.sh or your own) and a topic; an access token is optional. LocalSky posts one message per event with the headline as the title. Slack wants an incoming webhook URL; LocalSky posts the headline in bold and the detail on the next line. A sink that fails is logged and never blocks the others. There is no email channel: it would need an SMTP dependency this image does not carry, so the field was removed rather than shipped dead.

Web Push

Web Push is the closest thing to a real app notification without putting LocalSky in any app store. Once a phone or laptop opens the dashboard and subscribes, the OS-native notification surface fires even when the browser is closed. Notifications use a grouping tag, so a newer event for the same zone replaces the previous notification instead of stacking, and tapping one opens the relevant page (/irrigation or the zone’s detail page).

Web Push needs a VAPID keypair so the push service can verify that notifications are signed by your LocalSky instance. The keypair is generated once and reused for the life of the deployment.

LocalSky loads the keypair from environment variables at startup:

VariableWhat it is
VAPID_PRIVATE_KEY_PATHPath (inside the container) to a PEM private key file. Both PKCS#8 (BEGIN PRIVATE KEY) and SEC1 (BEGIN EC PRIVATE KEY) PEMs are accepted
VAPID_PUBLIC_KEYThe matching public key as unpadded base64url (87 characters): the raw 65-byte uncompressed P-256 point, the same applicationServerKey format browsers use. Padded or standard base64 is rejected at startup with a log warning
VAPID_SUBJECTOptional contact URI (mailto: or https:) the push service can use to reach you. Defaults to the LocalSky project URL

If the variables are missing or the key file is unreadable, the dispatcher logs one warning at startup and silently drops every event; the rest of the app keeps running.

1. Generate the keypair

openssl produces exactly what LocalSky loads:

mkdir -p ./localsky-keys

# Private key: SEC1 PEM ("BEGIN EC PRIVATE KEY"), P-256.
openssl ecparam -genkey -name prime256v1 -noout \
    -out ./localsky-keys/vapid-private.pem

# Public key: the raw 65-byte uncompressed point, base64url, no padding.
openssl ec -in ./localsky-keys/vapid-private.pem -pubout -outform DER \
    | tail -c 65 | base64 -w0 | tr '+/' '-_' | tr -d '='

The second command prints an 87-character string starting with B; that is your VAPID_PUBLIC_KEY. Keep the PEM file safe: the config backup bundle (GET /api/v1/backup) deliberately excludes the keys directory, so back it up yourself.

Note on the web-push Node CLI: npx web-push generate-vapid-keys emits the private key as a raw base64url scalar, not a PEM file. That string cannot be dropped into vapid-private.pem as-is (and wrapping it in BEGIN PRIVATE KEY markers does not make it PKCS#8). Use the openssl flow above instead; it needs no extra tooling.

2. Mount the key and set the environment

The private key lives in a host directory mounted read-only into the container. With Docker Compose:

environment:
  - VAPID_PUBLIC_KEY=BNJxRy7...87-chars
  - VAPID_PRIVATE_KEY_PATH=/keys/vapid-private.pem
  - VAPID_SUBJECT=mailto:[email protected]
volumes:
  - ./localsky-keys:/keys:ro

The app runs as uid 10001. Unlike the writable /data volume (whose ownership the container fixes automatically), the keys directory is mounted read-only, so the container cannot adjust it for you. Make sure uid 10001 can read the PEM on the host:

chown 10001:10001 ./localsky-keys/vapid-private.pem
chmod 440 ./localsky-keys/vapid-private.pem

Restart the container after setting the variables; the keypair is read once at startup.

The [notifications.web_push] block you may see in localsky.toml or GET /api/v1/config (vapid_public, vapid_private_path, vapid_subject) mirrors these env vars so the settings UI can display them. Setting the TOML block alone does not enable push; the environment variables are the live configuration path.

3. Verify the server side

curl http://localhost:8090/api/v1/push/vapid-key

A configured instance returns { "public_key": "BNJxRy7..." }. A 503 with { "error": "vapid not configured" } means the keys did not load; check the container logs for push: warnings (unreadable PEM path, malformed public key).

4. Subscribe a device

Open the dashboard on each phone / laptop / tablet that should receive notifications. Go to Settings -> Notifications -> Web Push and tap Subscribe this device. The browser asks for notification permission; allow it. The dashboard registers a push endpoint with the public key, and from that moment LocalSky can wake the device.

To stop receiving on a device: tap Unsubscribe in the same panel, or clear the site data in the browser. Endpoints that a browser has revoked are pruned automatically the next time a push to them fails.

Troubleshooting

  • The subscribe control reports push as unavailable: the server did not load a VAPID keypair, or the history database (where subscriptions are stored) was not openable at startup. GET /api/v1/push/vapid-key distinguishes the two: 503 means keys, and 503 from POST /api/v1/push/subscribe with "history db not configured" means the database.
  • iOS does not show notifications: iOS 16.4+ supports Web Push but only for PWAs added to the home screen via Share -> Add to Home Screen. A regular Safari tab will not ring.
  • No notifications after subscribing: confirm the server side with GET /api/v1/push/vapid-key, then trigger a test by manually running a zone; the zone-start event should arrive within seconds. Check the container logs for push: send ... failed lines.

What fires when

EventTrigger
Zone startedA zone’s running state flips from off to on
Zone stoppedA zone’s running state flips from on to off (carries the run duration in minutes)
Daily verdictThe first verdict computation of each day (skip / run / run extended, with the reason text)

There is no rate-limit or quiet-hours logic yet. If a misbehaving controller flaps a zone, every subscribed device hears every flap. Track the roadmap for a quiet-hours policy.

AI advisor (optional)

A fully optional natural-language layer over the engine’s state. Point LocalSky at any OpenAI-compatible endpoint, a local Ollama or llama.cpp instance on your network, or nothing at all.

What it does when enabled:

  • Writes the Advisor note on the irrigation dashboard: a one-or-two sentence plain-English read of today’s verdict (what will run or skip, and the concrete conditions behind it), shown under the hero verdict and refreshed as conditions change (the explanation is cached for about five minutes). Also available at GET /api/v1/irrigation/explanation.
  • Cross-checks the snapshot for anomalies: inconsistencies between the live station, the forecast, and the verdict (for example a rain gauge reading zero while the radar says it is pouring). Served as a structured list at GET /api/v1/irrigation/anomalies, refreshed hourly.

The advisor is a read-only narration layer; there is no chat interface. If the provider is unreachable, the dashboard simply omits the advisor note and the deterministic explanation stands on its own.

What it never does:

  • Make watering decisions. The deterministic engine decides; the advisor only narrates and explains it.
  • Send your data anywhere you didn’t point it. Local endpoints stay local; the provider is your choice and “None” is a first-class setting.

Configure under Settings > Logic > LLM advisor, or during setup (the step is skippable and defaults to off).

Units

Units are display only. The engine does all of its math in metric internally and converts at the boundary, so switching units changes what you read, not how LocalSky waters. (Zone area is the one value you enter yourself, so its unit does feed the water math; see Zones.)

Find the control under Settings, then Units. It has two independent layers, picked by the “Applies to” switch at the top.

Household default (whole deployment)

Imperial or Metric for the whole install. It is stored in /data/localsky.toml as deployment.units and travels on the irrigation snapshot, so every device that follows the household updates on the next tick. This layer has an explicit Save button, because it changes shared server config.

  • Imperial: F, inches, mph, inHg, miles, square feet.
  • Metric: C, mm, km/h, hPa, km, square meters.

The setup wizard pre-selects this from your location.

This device only (per browser)

A single device can opt out of the household default and keep its own units, saved in that browser’s localStorage. There is no Save button here: each pick persists the moment you make it, and a short “Saved on this device” line confirms it. Your other devices and the household default are untouched.

Pick a whole system (Imperial or Metric), or choose Custom to set each measurement on its own: temperature, rainfall, wind speed, pressure, distance, and zone area. Switch back to “Household default” and the per-device keys are cleared, so the device follows the deployment again.

Theme

The theme picker sets how LocalSky looks on this device. It is a per-browser preference, not a per-deployment setting: your choice is stored in this browser’s localStorage and no config is written, so two people looking at the same install can each pick their own theme.

Find it under Settings, then Theme. Pick a card and it applies instantly, no reload. A tiny boot script reads your saved theme before the first paint, so the page never flashes the wrong colors on reload.

The four presets

  • Dark (the default): the house look, glass panels over deep blue.
  • Light: a hand-tuned light theme, the same panels lifted to a bright background.
  • Auto: follow your operating system’s light/dark preference and switch with it.
  • High contrast: pure black on pure white with the glass effects dropped, for maximum legibility.

Because the choice lives in the browser, it does not travel with a backup or sync to your other devices; set it once per browser. Clearing site data resets you to Dark.

LocalSky Irrigation Engine

The engine answers one question: should I water tomorrow, and if so, how long? Every dashboard tile, every notification, every controller dispatch derives from a deterministic pipeline rooted in published agronomy and meteorology. This document walks through that pipeline end to end, with citations, so anyone with a slide rule and a quiet afternoon can reproduce the math by hand.

Pipeline overview

Weather sources ---------> MergedSnapshot -> Engine -> Verdict + per-zone runtime
Ecowitt GW (native poll) /                    |                |
                                              +-- FAO-56 ET0   +-> OpenSprinkler HTTP
                                              +-- Species Kc       (opensprinkler_direct)
                                              +-- Soil water balance
                                              +-- Skip rules (frost-skip uses native soil temp)
                                              +-- Cycle-and-soak
                                              |
                                              +-> Publishes results to HA
                                                  (sensor.localsky_<zone>_soil_*, valves, verdict)

LocalSky owns the full pipeline end to end: it polls the Ecowitt gateway directly, runs all ET and water-balance math internally, evaluates skip rules (including frost-skip against its own native soil-temperature readings), and actuates OpenSprinkler via a direct HTTP controller (opensprinkler_direct, targeting the controller’s LAN address). Results are published back to HA for display, but HA is a consumer, not a driver: nothing LocalSky decides is read from a Home Assistant entity. As of 0.7.22 that includes the skip thresholds and the four operator controls, which used to live in input_* helpers and were read one last time on upgrade. What still reads a Home Assistant entity is what you pointed at one by name (a zone’s soil sensor), your controller’s own entities on a legacy Home-Assistant-only install, and nine legacy sensor.open_meteo_* forecast fallbacks. The complete list, and what changed, is in Migrating your watering off Home Assistant. No Smart Irrigation, no Irrigation Unlimited, no MQTT sidecar.

Each box is a pure function of its inputs. No hidden state, no opinionated overrides, no proprietary fudge factors.

Inputs

Per source, per tick, LocalSky records:

  • Air temperature min / max / mean (deg C internally; converted from F at the boundary)
  • Relative humidity (max / min preferred, mean acceptable, dew point as fallback)
  • Wind speed at 2m (or 10m if measured higher; eq. 47 corrects)
  • Solar irradiance (W/m²)
  • Atmospheric pressure (kPa; elevation-derived if missing)
  • Rainfall (gross + intensity)
  • Observed rain over the recent window (today plus prior days’ measured totals, sensor-independent so a dropped soil probe or a paused source can’t hide real rain that already fell)
  • Day-of-year + latitude + elevation

Rainfall carries an honesty tier alongside the value: measured (a real gauge caught it), radar (a radar/QPE estimate), or model (a forecast figure). Downstream skip logic weights a measured total differently from a model guess.

Soil inputs (natively polled from the Ecowitt GW1100B gateway’s LAN address):

  • Per-zone soil moisture % (calibrated from raw FDR AD against dry/wet endpoints in LocalSky config)
  • Per-zone soil temperature (used directly for the frost-skip rule; no HA aggregation step)
  • Per-zone EC and battery state

If multiple sources report the same field, the merge engine picks the winner per merge policy: max for rainfall (one stuck gauge can’t hide actual rain), min for overnight low, highest priority for everything else.

Reference ET₀

LocalSky implements three methods. The Auto path tries them in order and picks the first one whose inputs are present.

1. FAO-56 Penman-Monteith (Allen et al., 1998 eq. 6)

The gold standard. Daily ET₀ over a hypothetical reference grass surface 12 cm tall, well-watered, with albedo 0.23 and a fixed surface resistance of 70 s/m:

ET₀ = (0.408 * Δ * (Rn - G) + γ * (900 / (T+273)) * u₂ * (es - ea))
      / (Δ + γ * (1 + 0.34 * u₂))

Where:

  • Δ – slope of vapor pressure curve at T_mean (kPa/°C), eq. 13
  • Rn – net radiation (MJ/m²/day), eq. 38 + 39 + 40
  • G – soil heat flux (~0 for daily timescale over grass)
  • γ – psychrometric constant (kPa/°C), eq. 8 = 0.665e-3 × P
  • T – mean daily temperature (°C)
  • u₂ – wind at 2m (m/s)
  • es – saturation vapor pressure (kPa), eq. 11 + 12
  • ea – actual vapor pressure (kPa), eq. 14-19 depending on humidity inputs

Rn is the trickiest term. LocalSky uses ASCE-EWRI 2005’s Brunt-form longwave model:

Rs   = measured shortwave (or 0.16 * sqrt(Tmax-Tmin) * Ra when missing)
Rns  = (1 - 0.23) * Rs       # net shortwave with albedo
Rso  = (0.75 + 2e-5 * z) * Ra # clear-sky from extraterrestrial
Rnl  = σ * ((Tmax+273)^4 + (Tmin+273)^4)/2 * (0.34 - 0.14*sqrt(ea)) *
       (1.35 * clamp(Rs/Rso, 0.3, 1.0) - 0.35)
Rn   = Rns - Rnl

Ra (extraterrestrial radiation, MJ/m²/day) is computed analytically from latitude and day-of-year via eq. 21, with the sunset hour angle clamped to [-1, 1] so high-latitude polar-day cases don’t NaN.

Implementation: src/engine/et0.rs. A regression uses FAO-56 chapter 4, Example 18 (Uccle, July 6): 3.88 mm/day, including the published 10 m to 2 m wind conversion.

2. ASCE-EWRI 2005 short-crop reference ET

Practically identical to FAO-56 for daily computation; the coefficients differ at sub-daily resolution where LocalSky doesn’t operate. Same code path as FAO-56 for LocalSky’s daily computation. The method actually used is chosen automatically from the inputs available; it is not selectable, and no screen reports which one ran.

3. Hargreaves-Samani 1985

Fallback when wind, solar, or humidity are missing:

ET₀ = 0.0023 * (Ra * 0.408) * (Tmean + 17.8) * sqrt(Tmax - Tmin)

Hargreaves is an approximation whose error depends on local climate and calibration. It is used when the inputs required for Penman-Monteith are missing. Daily peak wind and afternoon humidity are not daily means and cannot select Penman-Monteith.

Crop ET (ETc)

For each zone:

ETc = ET₀ * Kc(species, DOY, latitude)

Kc (crop coefficient) is dimensionless, looked up from the species catalog by zone’s grass species and the current day-of-year. The catalog ships 12 species + ornamentals + xeriscape with monthly Kc curves; LocalSky interpolates linearly between mid-month anchors, with Dec/Jan wrap, so the curve is smooth year-over-year. Citations live inline in src/engine/species_catalog.rs.

Reference ET already accounts for weather demand. LocalSky applies no extra heat-index or VPD multiplier to it; the legacy API multiplier fields remain 1.0. Human heat index remains a weather/advisory signal. Soil planning uses potential crop demand, a single root-zone bucket, and configured/catalog parameters; it does not claim to measure actual plant transpiration.

What decides watering today

Two models are selectable, per install and per zone. engine.scheduling_model picks the default: soil, which an install with no key set follows, or weekly, which an install follows only by pinning it here. The setup wizard writes soil explicitly for new installs, so the choice is recorded rather than inherited. A zone’s own scheduling_model field pins either model for that zone regardless of the engine default. The Engine settings page carries the install-wide selector and the zone editor carries the per-zone pin; both hot-reload, so a save applies on the next scheduler tick.

  • Weekly water balance (weekly): the model in the next section. A gross weekly target per zone, settled against rain, applied water, and a forecast credit; the remainder splits across the week’s sessions.
  • Soil model (soil): the FAO-56 depletion bucket below. Each zone waters when its own soil deficit crosses the trigger, and each run refills the deficit, so cadence follows soil texture and roots instead of sessions_per_week.

Whichever model governs, the soil model computes on every install for every zone that has a zone config, and publishes a bucket for it once that zone has at least three evidenced days and the replay has resolved its unknown initial moisture. The Deficit tiles on the zone card, the zone detail and the dashboard show the replayed deficit (negative = needs water), the zone detail’s Soil model block shows what the model waters, or would water, today and when it waters next, and the tuning report carries a comparison line on every weekly-governed zone. A zone with no agronomy config (env-var zone lists) stays on the weekly model and its deficit reads a dash.

The soil model

The bucket arithmetic lives in src/engine/water_balance.rs and the planner in src/engine/soil_schedule.rs:

depletion[t+1] = clamp(depletion[t] + ETc - effective_rain - applied_water,
                       0, TAW)
needs_irrigation = (depletion >= RAW),   RAW = TAW * MAD%

with TAW from the soil texture catalog at the species’ default or overridden root depth, and MAD per species.

Replay. Every evaluation replays up to 14 local days, beginning with the first evidenced day. Leading unknown days do not invent a drought. Two copies start at opposite limits: field capacity and wilting point. A deficit becomes available only when their final values agree within 0.1 mm and at least three days carry evidence. Deep roots or low winter ET can leave the initial state uncertain beyond that window; elapsed time alone never establishes it. When the point is uncertain, the planner evaluates both interval bounds. Agreement can support a hold or a minimum evidenced refill; disagreement holds automatic watering with an uncertainty explanation. With fewer than three evidenced days, the weekly target remains the named fallback.

Each date charges its own reference ET times seasonal, hemisphere-aware Kc. Effective rain and completed valve-open intervals refill the bucket. The ET ladder uses recorded daily evidence, then the forecast archive, then the zone’s weekly-target-derived demand assumption for gaps within the known span. A reported zero ET remains zero. Today’s partial ET uses an entirely covered remaining-day curve, or the elapsed fraction of that actual local day’s length.

Trigger and rain timing. The planner projects depletion through daily plant water use and forecast rain until the next legal watering morning. Waiting is appropriate while reserve stays below RAW. Rain is applied after the day’s demand in this conservative arrival-time calculation; forecast probability and local bias adjust its expected contribution. A distant storm cannot erase a stress threshold crossed before that storm arrives.

Sizing. Without useful upcoming rain, a run refills the current deficit. When rain can help but waiting would cross the trigger, the planner solves for a smaller bridge through its arrival, even when another legal morning comes first. Each candidate refill is replayed through the daily balance and drainage; water applied before a filling storm cannot remain as credit after that storm. Gross seconds are net refill divided by capture efficiency and application rate, capped by the zone’s run limit. An explicit weekly budget remains a delivery ceiling; an inferred target does not. If current storage and legal frequency cannot cover demand, the reason explains that limit. Watering beyond field capacity would drain below the roots.

Forecast misses. Actual rain and completed watering update the next replay. A shower that fails to arrive leaves greater depletion and a larger required refill; the horizon does not grant arbitrary days of plant stress. Existing morning decisions remain in history with their original reasons.

The outlook. The same sizing and decision passes advance through a scenario copy of the balance, carrying modeled irrigation and legal watering days forward. They never write forecast water into measured history. The scenario checks the forecast’s current age separately from the future day. Beyond hourly coverage, daily rain totals provide a coarse projection; live dispatch still requires fresh, complete hourly planning data. Current sensors, restrictions and safety checks remain authoritative at the actual morning.

Window admission. When several zones trigger on the same morning, they are admitted most-stressed-first (deficit over RAW, descending) against the pre-sunrise window, priced by the dispatcher’s own wall-time arithmetic (cycle-soak splits, soak gaps, interleaving). The most-stressed zone is always admitted, even alone over the window. A zone that does not fit waters tomorrow at higher stress and says so in its reason line; nothing is dropped silently.

Gates. Every safety and compliance gate still binds: wind, freeze, rain now, already wet, the observed-rain backstop, soil-probe saturation and quarantine, watering restrictions, pause, dry-run. Three forward-rain gates (rain within 4 hours, tomorrow rain, 3-day rain) and the heat-advisory extension are inert for soil-governed zones: defer by deficit already prices forecast rain against the deficit, and measured ET0 already charges hot days into it, so those gates would count the same signal twice. The rule catalog names this on each row; weekly-governed zones keep all four.

Weekly fields under the soil model. A hand-set weekly_budget_in stays honored as the delivery ceiling above. sessions_per_week stops steering: cadence is emergent, roughly RAW divided by daily ETc, which is the same interval the tuning report’s bucket check computes. Both keep their full meaning for weekly-governed zones. rain_credit_cap_in keeps its per-day meaning inside the replay, and an unset cap is emergent: the [0, TAW] clamp already bounds what a single day of rain can credit.

Weekly water balance

The model a zone follows when the install or the zone pins weekly. The weekly allocator sizes each zone’s sessions against a true water balance in gross homeowner terms: the target is “inches per week including rain,” and the week’s ledger settles before any session is sized.

rain_cap_mm             = rain_credit_cap_in * 25.4, or unset:
                          (field_capacity - wilting_point) * root_depth_mm
credited_rain_mm        = sum over trailing days of min(day_rain_mm, rain_cap_mm)
weekly_target_gross_mm  = weekly_budget_in * 25.4
remainder = max(0, weekly_target_gross_mm
                   - credited_rain_mm
                   - irrigation_applied_trailing_mm
                   - bias_corrected_forecast_credit_mm)
session_gross_mm    = remainder / remaining_sessions
seconds_per_session = session_gross_mm / throughput_mm_hr * 3600   (capped at the run limit)
  • The trailing window is a rolling 7 local days ending now; there is no calendar-week anchor.
  • Observed rain resolves through a ladder with per-rung provenance and COVERAGE precedence: when the observations ledger holds any gauge or radar day rows for the window, the measured record wins outright, even at 0.00 in (a yard that measured a dry week is ground truth a wetter regional model must not override). Only when measured coverage is entirely absent does the forecast provider’s past-day model archive supply the term, and an install with neither runs on the corrected forecast alone; the tuning report line names which rung applied. The winning rung supplies both the raw window total (which rides the wire as observed_rain_mm) and the per-day series the credit is computed from.
  • Rain credits per day, each day capped at rain_cap_mm, the root zone’s own capacity (TAW from the soil texture catalog at the species’ default or overridden root depth, or the zone’s explicit rain_credit_cap_in). Rain beyond the cap in a single day drains below the root zone and never becomes plant-available, so a 1.2 in storm day on sand credits about 0.35 in rather than settling a 1.0 in week outright. A week whose rain never exceeded the cap on any day settles exactly as the raw sum. The cap does not decay older rain by ET: the weekly target already encodes typical ET, and decaying the credit would count it twice. Each forward forecast-credit day is held to the same cap.
  • Applied irrigation is the union of completed watering evidence in the window (duplicate manual/observer intervals count once; soak gaps supply no water) times the zone’s precipitation rate. Gross in against a gross target: no capture factor on either side. Every completed run counts, whoever commanded it: water in the ground is water in the ground, so a manual run and a manual schedule’s run shrink the remainder and move the session-spacing anchor exactly as a smart run does.
  • The forecast credit covers only the days between tomorrow and the zone’s next expected session, corrected by the per-month bias multiplier (below). Rain past the next session is never credited now; it will be observed rain by the time it matters. Imminent rain is handled by the 24-hour defer gate, not the credit.
  • The 24-hour defer gate compares the next 24 forecast hours against engine.session_rain_defer_in (default 0.10 in), weighting each hour by its precipitation probability, the same weighting the forward credit uses. An hour with no reported probability weights at full value. Before 0.7.22 the gate summed the raw model depth and read a compile-time constant instead of the configured threshold, so a low-probability drizzle could zero every zone almost daily and raising the documented knob changed nothing. Soil-governed zones replace this fixed depth with defer by deficit (above).
  • remaining_sessions is sessions_per_week minus the completed events in the window, floor 1. A full nominal session earns floor(7 / sessions_per_week) local days of spacing. When its delivered depth is known, a partial session earns that interval times its fraction of nominal session depth, rounded down with a one-day minimum. Unknown historical depth retains configured spacing.

Fixed in 0.7.17: the previous formula multiplied delivery by the heat multiplier and divided by capture efficiency (0.70), inflating session length by up to about 1.9x against a target that already reads as gross, and it credited only forward forecast rain: rain that had already fallen and water already applied never counted, so a soaked week could still schedule full sessions. In 0.9.0, ETc is reference ET times seasonal Kc, without an additional heat multiplier. The soil balance honors its capture-efficiency assumption separately from this gross weekly target. Relative probe calibration does not establish volumetric water content, so those readings no longer produce an unsupported future percentage curve.

Implementation: src/engine/budget.rs (the one pure implementation; the refresher assembles its inputs).

Cycle-and-soak

If applying the full runtime at the sprinkler’s precipitation rate would exceed the soil’s infiltration capacity, water runs off instead of soaking in. The splitter divides the total runtime into N cycles separated by soak gaps:

if precip_rate > infiltration_rate:
    max_cycle_minutes = (infiltration_rate / precip_rate) * 60
    N = ceil(total_runtime / max_cycle)
    each cycle = total_runtime / N
    ponded_mm = (precip_rate - infiltration_rate) * cycle_hours
    soak after every cycle but the last, in minutes =
        max((ponded_mm / infiltration_rate) * 60, texture_floor_min, soak_minutes)

infiltration_rate comes from the soil catalog, varying by texture and slope (flat / 3-5% / >5% bands per USDA NRCS Part 652 Table 11-3). Sand on flat ground: 50 mm/hr; clay on a steep slope: 3 mm/hr.

The soak is derived per zone, not configured. A cycle leaves (precip_rate - infiltration_rate) * cycle_length of water standing when the head shuts off, and that depth drains at the soil’s own infiltration rate, so the soak is however long clearing it takes. Clay waits longer than sand without being told to, and a zone whose head applies water slower than the soil takes it never soaks at all.

Two floors sit under the derived figure. The first is per texture: 5 minutes on sand and loamy sand, 10 on the sandy loam / loam / silt loam family, 15 on clay loam and clay. The drain arithmetic assumes a steady intake rate, but real surfaces start fast and slow down, water moves sideways under the canopy, and a head’s pattern is never uniform, so a soak computed at a few seconds is not a pause the soil would recognize. The second is engine.soak_minutes, your own floor for a surface you know needs longer than the catalog says. It defaults to 5, takes 5 to 120 minutes on the Engine settings page, and is a floor only: it raises a short soak and never shortens a long one. It used to be the soak itself, one number for every soil, and its old default of 30 was clay’s answer applied to sand. soak_minutes hot-reloads with the rest of the watering policy, so a change applies on the next evaluation, no restart needed.

Worked example: clay (5 mm/hr infiltration on flat), MP rotator heads at the catalog’s 14 mm/hr, 45-minute total runtime -> 3 cycles of 15 min with two 27-min soaks. Those 27 minutes are derived, not a default: each 15-minute cycle puts down 3.5 mm, the clay takes 1.25 mm of it while the head runs, and the 2.25 mm left standing clears in 27 minutes at 5 mm/hr. Total elapsed wall-clock: 1h 39min. Total water applied: same 45 minutes worth, but it actually enters the root zone instead of running off.

Implementation: src/engine/cycle_soak.rs.

Cycle interleaving

With interleave_cycles = false the morning sequence is strictly serial: a zone runs every one of its cycles, idling through each soak, before the next zone starts. In the worked example above that is 1h 39min of wall clock to apply 45 minutes of water, and every other zone waits behind it.

interleave_cycles = true in the [engine] table (the default, and the toggle on the Engine settings page) interleaves instead: during one zone’s soak pause, another zone’s cycle runs, the way dedicated irrigation controllers handle cycle-and-soak. The planner lays every zone’s cycles on a single valve timeline, dispatching whichever zone can start earliest. The rules it never breaks:

  • One valve at a time, always. Interleaving never opens two zones together, regardless of what the controller hardware could do.
  • Every soak is a minimum, not an exact gap. A soak stretches when another zone’s cycle is still running as it expires; it never shrinks.
  • Each zone’s cycles run in order, and the sequence never takes longer than the serial plan.

Worked example, continued: add a rotor zone that needs one 20-minute pass. Serial, the sequence takes 1h 39min for the clay zone plus 20 minutes for the rotor, about 1h 59min. Interleaved, the rotor pass runs inside the clay zone’s first 27-minute soak and the whole sequence finishes in the clay zone’s own 1h 39min.

The default is on: with a municipal or otherwise pressurized supply, the shorter sequence is strictly better. Turn it off on installs fed by a well or a low-recovery pump, where the serial plan’s idle soak gaps double as recovery time for the water source and interleaving would fill that idle time with more pumping. The setup wizard’s water-supply question sets this for you; the toggle lives on the Engine settings page. The setting hot-reloads with the rest of the watering policy, so a change applies on the next scheduler tick (the next morning’s plan), no restart needed.

Either way, the scheduler works the sequence’s true wall time (runs, soaks, and preambles) backwards from its sunrise finish target, so a cycle-and-soak morning still ends about 15 minutes before sunrise.

Implementation: src/engine/interleave.rs.

Seasonal water budget

Three things size a run: the scheduling model, which decides how much water a zone is owed; the cycle-and-soak pacing above, which decides how that water gets delivered; and the seasonal water budget, a single dial over the top of both. The thresholds that skip a run outright are a different mechanism, enumerated in skip-rules.md.

engine.seasonal_adjust_pct is that dial: a percentage of the computed run depth, 100 by default, which means every run waters exactly what the math above produced. Dial it down in a wet, cool stretch and up in a heat wave, the same control a commercial controller labels seasonal adjust. It lives on the Engine settings page as a 50 to 150% slider, and the multiplier is clamped to that same range in code, so a hand-edited config still cannot triple a run or dry out a yard. A dial of 0 reads as “never set” and changes nothing, rather than watering nothing.

The order the dial is applied in is load-bearing. The dial scales the raw budget first, then the per-zone maximum duration clamps the scaled figure, then the force-run floor gives a zone you deliberately forced a bounded default if the result came out zero, and only after all of that does a condition rule’s multiplier layer on and get re-clamped to the same ceiling. Backwards, a dial above 100% would take an already-capped run and push it back over the ceiling, and that ceiling is the tighter of your zone’s own limit and any duration cap an active watering restriction imposes. Getting the order wrong is not merely untidy: it can dispatch past a legal limit.

Because the dial is applied while the snapshot is assembled rather than at dispatch time, the planned minutes on the zone card and in the math panel already reflect it, so display and dispatch agree. A run that lands on its ceiling because the dial pushed it there reports the cap as the binding constraint, the same way a run the weekly allocator capped does.

The dial applies whichever scheduling model governs the zone: a soil row’s seconds run through it exactly as a weekly row’s do, including when the pre-sunrise window prices which zones fit. It hot-reloads with the rest of the watering policy, so a change applies on the next evaluation, no restart needed.

Implementation: src/engine/sizing.rs (seasonal_multiplier, seasonal_capped), applied in src/assembly/mod.rs.

Skip rules

Before any zone fires, the engine runs one deterministic ladder in that zone’s scope. First matching applicable gate wins. Operator holds, applicable watering restrictions, unavailable weather, and enabled freeze, soil-frost, and wind protections remain binding. A confirmed Force can set aside rain and soil recommendations; it cannot silently waive those protections. Ordinary runs also pass through the owner’s condition rules, and Rhai script holds apply to every runnable decision. Dispatch consumes these completed zone verdicts, and the yard headline summarizes them, including mornings when only some zones can run.

Observed recent rain (measured and sensor-independent) is checked before both the soil and forecast gates: if enough real rain has already fallen over the recent window, the zone skips regardless of what a probe or a forecast says. A soft forecast-rain skip is not the last word, though: when a zone reads measured-dry, the engine can demote that forecast skip back to a run so soil truth wins over an uncertain forecast (the soil floor moat). And an offline or outlier soil probe does not silently break a zone: its state is inferred from trustworthy neighboring probes (soil quarantine) so one bad reading can’t force a skip or a needless run.

Full enumeration in skip-rules.md. Thresholds are typed config fields in cfg.engine.skip_rules. The 0.9.0 fixes can change a decision that previously missed a hold or disagreed with its zone card; existing configured thresholds are preserved.

Heat advisory pre-water

When the configured heat, humidity, and recent-dryness thresholds are met, the weekly model can return run_extended instead of plain run. This labels the decision; it does not add a separate 15% runtime multiplier. The water balance and zone sizing determine the minutes. Soil-model zones already account for hot weather through modeled water use and do not get a second heat adjustment. The advisory stays inactive when forecast rain sufficiently covers demand.

7-day forward verdict strip

Every dashboard render projects the next 7 days through the same rule ladder, using the daily forecast as synthetic Inputs. The “preview” is the actual decision the engine would make if today were that future day, with the live-only signals (wind_now, rain_intensity_now) zeroed out so they don’t false-fire. Operator gets a glance-able strip showing “skip Tuesday because heavy rain forecast”, “run extended Friday because heat advisory”, etc.

Implementation: src/engine/verdict_strip.rs.

Provenance

Every field in the merged snapshot records source_id, observed_at, and an optional method tag. The dashboard’s math tile reveals “ET₀ 5.2 mm via tempest_lan (penman_monteith)” or “wind 8 mph via open_meteo (forecast)”. Operators always know which input drove which decision; no opaque “the system says so.”

Forecast bias correction

Open-Meteo, NWS, and every other regional forecast source carries systematic bias in any given microclimate. A bowl behind a hill that sees consistent overprediction in summer afternoons doesn’t need the operator to hand-tune their rain-skip threshold every season; LocalSky learns the bias from observed data and folds it out.

How it works

Every refresh, LocalSky records one row per local calendar day in forecast_observations:

columnsource
predicted_inThe first available forecast total for that local day (forecast.daily[].precip_sum_in). A missing prediction retains the existing internal -1 marker, excluded from bias fitting; a later valid prediction can replace that marker. A real prediction, including zero, is then held fixed.
observed_inThe day’s observed rain from the merge-contested daily total. Day-max: the recorded value only ever rises within a day, so a gauge going stale mid-storm cannot reset the total.
observed_sourceWhich kind of source supplied the day’s max: gauge or radar for measured day totals, or none (a placeholder 0.0, excluded from the bias fit, the dryness counters, and the scorecard). A model-nature rain owner also records the placeholder: its “rain today” is the whole day’s forecast, including hours that have not happened, and the day-max semantics would make phantom rain permanent. Rows written before 0.7.17 read legacy and count as gauge-quality only on installs with a station source.
month1..12, denormalized so the bias query indexes by month-of-year.

Measured rain continues to be recorded even when a forecast is unavailable. The first valid prediction is held fixed; later refreshes refine the observation. The hard recent-rain gate accepts only explicitly tagged gauge/radar history, including measured zero; model and legacy rows cannot establish that measured backstop. Historical bias fitting retains its separate compatibility policy for legacy rows. Once MIN_OBSERVATIONS (currently 5) usable days exist in a given month within the rolling 90-day window, the engine computes a per-month bias multiplier:

multiplier = median(observed_in / predicted_in)   over the month bucket
multiplier = clamp(multiplier, 0.5, 1.5)

Multiplicative not additive: rain bias is the same shape at 0.2 inch and 2.0 inch. Median not mean: a single 2-inch surprise storm shouldn’t tank the model.

Where it surfaces

  • API: GET /api/v1/forecast/bias returns the current-month multiplier plus the full 12-month table with sample counts.
  • Pure module: engine::forecast_bias::BiasModel::from_observations(observations, today, window) is callable from anywhere; ideal for backtests and replay against historical verdict logs.
  • The weekly balance: the balance’s forward credit is the multiplier’s first engine consumer: credit = forecast_rain * precip_weight * multiplier_for(month) over the days until the zone’s next session. Under-trained months multiply by 1.0 by design, and the tuning report states the sample count instead of implying a correction. The multiplier applies only to forward forecast, never to observed terms. Rows whose observed side had no rain-capable source (observed_source = 'none') are excluded from the fit, so gauge-less installs cannot train the floor on fabricated dry days.
  • Skip rules: the multiplier is not yet folded into the rain inputs going into the skip ladder; wiring corrected_rain = raw_rain * multiplier upstream of skip_rules::evaluate remains planned. The same observation rows already do decision work elsewhere: the observed-rain backstop reads them live, and the tuning report’s forecast-skip scorecard judges every rain-family skip against them.

Defaults and bounds

ConstantValueWhy
MIN_OBSERVATIONS5Below this, a single outlier dominates. Multiplier stays at 1.0.
BIAS_FLOOR0.5Real bias rarely halves a forecast; below this is almost certainly a broken pipeline.
BIAS_CEIL1.5Same intuition on the other side.
DEFAULT_WINDOW_DAYS90One season. Tracks microclimate shifts without dragging in last year’s summer into this year’s.
NOISE_FLOOR_IN0.02Below this in both columns, the day is “dry” and not informative for a multiplicative model.

Implementation: src/engine/forecast_bias.rs (pure functions + 11 unit tests).

Results-based tuning

The engine’s per-zone parameters (texture, root depth, sprinkler rate, weekly budget) start as informed guesses. The tuning report closes the loop: it reads a window of recorded outcomes and emits at most one deterministic recommendation per zone. The user-facing walkthrough is Tuning report; this section covers the machinery.

How it works

Four checks run per zone over a 7 to 30 day window (default 14), each a pure function over persisted rows:

checkreadsflags when
cap clamplive run-duration math + run days in the windowthe duration cap chronically trims the model’s desired session
interval plausibilitysoil catalog RAW / forward mean daily ETcone filling of the root zone lasts under 1.5 or over 21 days
drying driftprobe series slope across dry stretches vs mean_daily_etc / TAWthe measured drying rate is outside 0.6x to 1.6x the modeled rate
rate backoutprobe rise bracketing each watering eventthe backed-out precipitation rate differs from the configured one by over 30%

The install-wide forecast-skip scorecard builds on the existing accuracy scoreboard above (same forecast_observations rows, same WET/SIG thresholds as assess_day), extended with window-aware confirmation: a tomorrow-rain skip is judged against the NEXT day’s observed total and a 3-day-rain skip against the following 3-day sum, where the accuracy scoreboard judges same-day only. Only forecast-driven skips enter the tally (rain expected within 4 hours, tomorrow rain, 3-day rain); reactive skips (rain now, observed rain, already wet) are triggered by rain that already happened, so scoring them against observed rain would be self-confirming, and they are counted on a separate unscored line instead.

Where it surfaces

  • API: GET /api/v1/irrigation/tuning returns the full report; POST /api/v1/config/zones/apply writes one recommendation through the validated config path.
  • Pure module: engine::tuning holds every rule (slope estimator, event clustering, backout math, scorecard scoring, ranking) with unit tests; the store assembly is a thin layer above it.
  • UI: the zone detail’s Tuning panel and the irrigation page’s strip.

Defaults and bounds

ConstantValueWhy
window7..=30 days, default 14Enough mornings to call a pattern chronic; short enough to track the season.
dry stretch>= 48h, >= 2 stretches, >= 8 readings eachOne quiet weekend is not a drying signal.
drift band0.6x to 1.6xProbe percent is a relative scale; only a large, repeated ratio is trustworthy.
backout events>= 3 clean events, median rateA single rise can be rain residue or a probe artifact.
rate tolerance30%Catalog rates are honest to roughly this band anyway.
scorecard minimum3 scored daysBelow this the tally would be noise; the report says so instead.

One recommendation per zone at most, ranked cap clamp > drying drift > rate backout > interval plausibility, and the backout check is only consulted when drift did not flag the zone in the same report. Counts with no data behind them are null on the wire, never zero.

Implementation: src/engine/tuning.rs (pure rules + unit tests) and src/tuning/mod.rs (store assembly).

Where to read further

Skip Rules

LocalSky checks each zone through the same decision ladder every morning and whenever conditions change. First matching applicable gate wins. Active owner holds, watering restrictions, unavailable weather or configured probes, and enabled freeze, frost, and wind protections remain binding when Force is selected. Rain, reliable soil readings, and structured condition recommendations can be bypassed by an explicitly confirmed Force choice. Script holds remain binding.

Source: src/engine/skip_rules.rs.

Ladder

#RuleTriggerThresholdTunable?
0Restart requiredsaved configuration changed a startup-only dependencyuntil process restartnone
1Manual override: skip tomorrowis_tomorrow && override_tomorrow == "skip"noneUI
2Manual override: run tomorrowis_tomorrow && override_tomorrow == "run", after safety and hold checksnoneUI
3Vacation pause (timed)pause_until_epoch > now_epochnoneUI
4Vacation pause (toggle)is_paused == truenoneUI
4bWatering restrictionsa configured restriction blocks this day, date, or houryour restriction rulesUI
4cLive weather unavailablelive_readings == Unavailable (no station data, no forecast)nonenone
4dConfigured soil probe unavailablethis zone’s bound probe is missing or untrusteda reliable reading must returnnone
4eWatering-plan rain unavailablethis zone’s automatic plan lacks rain evidence for the next 24 hourscomplete interval coveragenone
5Currently rainingrain_intensity_now_in_hr > 0.010.01 in/hr (0.25 mm/hr)rain_now_in_hr
6Freeze risk nowtemp_now_f < min_temp_f38°F (3.3°C)min_temp_f
7Overnight freezetemp_min_24h_f < min_temp_f38°F (3.3°C)min_temp_f
8Soil frostsoil_temp_yard_min_f < frost_skip_soil_f35°F (1.7°C)frost_skip_soil_f
9Wind too high nowwind_now_mph > max_wind_mph10 mph (16 km/h)max_wind_mph
10Windy day forecastwind_max_today_mph > max_wind_mph + 5+5 mph (8 km/h) slackwind_forecast_slack_mph
11Already wetrain_today_in >= 0.050.05 in (1.3 mm)already_wet_in
11aRain forecast todayexpected rain meets the wet-day threshold0.05 in (1.3 mm), forecast rather than measuredalready_wet_in
11bObserved rain recentlyrain_observed_recent_in >= rain_skip_in0.25 in (6.4 mm) over the recent windowrain_skip_in, rain_observed_window_days
12Zone soil-saturatedthis zone’s effective moisture % >= saturation thresholdper-zoneper-zone soil settings
13Rain in next 4 hoursrain_next_4h_in >= 0.100.10 in (2.5 mm)rain_next_4h_skip_in
14Tomorrow rain (confidence-weighted)forecast_in * prob/100 >= rain_skip_in0.25 in (6.4 mm), weightedrain_skip_in
153-day rain rolluprain_3day_weighted_in >= 1.5 * rain_skip_in1.5x multiplierrain_3day_factor
15bSoil floor (the moat)a soft forecast-rain skip meets a zone measured below its dry floorper-zone target_min_pct_soilper-zone soil settings
16Heat advisory (pre-water)3-day max >= 95°F (35°C) + humidity >= 60% + 2+ dry dayscompositeheat_advisory_*
17Dry-run modeis_dry_run == truenoneUI
-Default(no rule matched)nonerun

Rules 4b, 4c, 4d and 4e have no off switch, for the reasons in Disabling a gate below. Rule 15b demotes a soft forecast-rain skip for a zone that is measurably dry, and it gets its own section under Soil floor (the moat).

A missing or untrusted configured probe holds its own zone, even when a neighboring probe reads dry. A zone with no probe binding uses its weather and soil model normally. Manual schedule weather waivers cannot bypass this data hold.

Both weekly and soil automatic plans require complete next-24-hour rain evidence. Missing amounts or gaps hold the affected zone with planning_forecast, including under convenience Force; Force cannot turn an unavailable plan into a minimum-duration run. Explicit manual durations use the separate manual-run and schedule-waiver policy.

The restart_required gate holds watering after a saved change to a startup-only dependency, such as controller bindings or location. It blocks Force and manual runs as well as scheduled watering, and only a process restart clears it. Later settings saves cannot clear the hold. Threshold-only tuning still applies live. The persistent restart notice explains the pending changes and offers a restart; Stop remains available.

The Force control changes scheduled decisions; it does not start a valve immediately. Its confirmation explains the risk of overwatering and wasting water. A global or per-zone Force remains selected until you choose Auto. A global Skip still holds a zone whose own control is set to Force. Configured safety protections and restrictions, rain delay, vacation pause, and hold-all remain in effect. The separate, consequence-confirmed weather waiver on a manual schedule is described in Manual schedules.

Disabling a gate

These gates are deterministic and they decide first: the same inputs give the same verdict every time, and nothing you write yourself is consulted until they are done.

Weather and soil recommendation gates can be switched off, which is most of the table. Availability checks remain protected. Rule Lab lists each configurable gate with a plain sentence about what turning it off costs you (“watering can start while it is actively raining”), and the switch makes you confirm that sentence before it saves. Re-enabling is the same switch and asks nothing.

Control, legal, and availability gates have no switch. The restart-required hold, manual override, both vacation pauses, dry-run mode, the watering restrictions you configured, live-weather availability, configured-probe availability, and automatic-plan rain availability are enforced whatever the disable list says. Naming one of them in that list by hand does nothing: the list is filtered before the ladder reads it.

Disabling is config, not code. The switch adds the gate’s id to engine.skip_rules.disabled_rules and taking it back out re-enables the gate; every config write snapshots the previous file first, so the state before you touched anything is still on disk and can be rolled back. A disabled gate keeps its row in the decision trace, marked “disabled by operator” rather than disappearing, so a verdict that surprises you still shows the gate you switched off months ago.

Verdict types

The ladder returns one of three verdicts:

  • skip: don’t irrigate. reason carries a human-readable explanation.
  • run: proceed with the engine’s computed runtime.
  • run_extended: water, and mark the run as extended. Rule 16 (heat advisory pre-water) fires it, and so does a condition rule whose action is extend. The verdict is a label on the decision, not a percentage applied to the dispatched seconds: what sets the minutes is zone math, and the only rule action that moves them is a scale factor.

Per-rule details

Currently raining (rule 5)

Live precipitation intensity from the Tempest hub (or merged from any source advertising RainIntensityInHr). 0.01 in/hr (0.25 mm/hr) is essentially “you can see the pavement getting wet”; anything above triggers the skip.

A hard “currently raining” skip only applies when the rain source is observation-grade: a local gauge, an NWS observation, or NOAA MRMS radar. A model forecast rain rate is treated as a soft skip that a measured-dry zone can demote to a run (see Soil floor (the moat)).

Freeze + soil frost (rules 6-8)

Three independent freeze checks. Air temp now blocks daytime watering on a cold front. Forecast overnight low blocks a 6 AM run when the lawn would freeze later. Soil frost is the strongest signal: cold soil + a sprinkler is how you ice a lawn.

Soil temperature comes from any source providing soil_temp_yard_min_f. If no source reports it (probe offline), this rule silently no-ops and the verdict surfaces “(weather rules only; soil rules offline)” instead of a false-clear.

Wind (rules 9-10)

Two thresholds: live wind right now, and forecast peak with a 5 mph (8 km/h) slack on the latter (forecast peaks tend to overshoot real maxes). Operators with sensitive sprinkler types (mp_rotator, drip) want max_wind_mph lower (~6 mph / 10 km/h); rotor heads tolerate up to 12-15 mph (19-24 km/h).

Already wet (rule 11)

Fixed floor at 0.05 in (1.3 mm) of accumulated rain today. Configurable but rarely changed, it’s a sanity check that says “I’m not going to add water to a wet lawn.”

Only observation-grade rain can establish this fact. Forecast rain has its own softer rule and explanation; a model archive cannot be presented as measured rainfall in this gate or the recent observed-rain backstop.

Observed rain recently (rule 11b)

The sensor-independent backstop. rain_observed_recent_in sums today’s measured rain plus the past rain_observed_window_days (default 1) of measured daily rain totals, and skips watering on its own when that sum reaches rain_skip_in (default 0.25 in / 6.4 mm). This is what makes a real afternoon rain suppress the NEXT morning’s run: it carries measured rain forward independent of any soil probe or forecast. Because it reads PAST observed rain rather than a forecast, it is not gated on forecast staleness. It is a hard skip that binds every zone (the soil-floor moat below never demotes it).

Yard-wide soil saturation (rule 12)

Each zone’s saturation gate uses its own effective moisture reading and threshold. A saturated zone skips while an eligible dry neighbor may run. When every zone is saturated, the yard reports a skip as well. LocalSky makes and dispatches these decisions itself; no Home Assistant automation is required. Missing readings remain unknown, or are explicitly inferred by the quarantine policy below.

Forecast rain (rules 13-15)

Three look-ahead windows: next 4 hours (hourly forecast), tomorrow (probability-weighted to deflate uncertain forecasts), and 3-day rollup. The 3-day uses a 1.5x multiplier on the user’s rain-skip threshold to require more total rain before skipping (a wider window is a weaker signal).

Missing rain is unknown, including when a provider’s weather request succeeds but its precipitation request fails. An enabled forecast-rain gate holds on missing amount evidence and explains what is unavailable; reported zero over a fully covered interval means dry. The measured-dry soil exception below still applies to soft forecast recommendations, while the separate automatic-plan availability gate remains protected. Unknown rain also cannot justify a heat-advisory extension or a forecast deferral beyond its evidence.

Soil floor (the moat)

A soft, forecast-based rain skip (next 4 hours, tomorrow, or the 3-day rollup) may be demoted to a run when a zone is measured healthy-dry: its soil percent is below its per-zone dry floor, target_min_pct_soil, with a present probe reading above zero. This honors measured soil truth over an uncertain forecast. Hard skips (measured rain now, observed recent rain, freeze, wind, soil saturation) are never demotable, and observation-grade rain (a real gauge or MRMS radar) never demotes.

Bad or offline soil probes (quarantine)

When soil_quarantine_enabled is true (the default), a probe that is offline or reads as a wild outlier versus its siblings (beyond soil_outlier_threshold_pct, default 35 pp) is distrusted, and that zone’s effective soil for the saturation and dry-floor gates is inferred from the trustworthy sibling readings. This stops a single bad-spot probe from driving a saturated zone to water, while a genuinely saturated zone still skips. Set soil_quarantine_enabled to false to restore the exact pre-quarantine behavior.

Heat advisory pre-water (rule 16)

The only built-in rule that can fire run_extended; one of your own condition rules can too. Triggers when:

  • temp_max_3day_f >= 95°F (35°C; or operator’s heat_advisory_temp_f)
  • humidity_now_pct >= 60% (heat_advisory_humidity_pct)
  • days_since_significant_rain >= 2 (heat_advisory_dry_days)
  • rain_3day_weighted_in < 0.5 * rain_skip_in (forecast doesn’t cover it)

Disabled in cooler climates by raising heat_advisory_temp_f.

Condition rules

The ladder is fixed. On top of it you can build your own rules in Rule Lab, each one a scope (every zone, or a named few), a condition tree over the weather and per-zone soil metrics, and a single action: skip the zone, mark its run extended, or scale its run by a factor. Only the scale factor moves the dispatched minutes; extend labels the verdict and leaves the run length alone.

Those three actions are the whole list, on purpose. A rule can add a skip, tag a run as extended, or resize one; it can never do the opposite. There is no action that clears a freeze, a wind gate, a watering restriction, or a rain skip, and none that forces a run. A scale factor is clamped to 0.5-1.5 no matter what the config file says, and the scaled run is re-capped at the zone’s maximum run time, so scaling up cannot push a run past its ceiling.

Condition rules run for every otherwise eligible ordinary zone, including a measured-dry zone that demoted a soft forecast skip, a soil-model zone that already credited forecast rain, and a zone exempt from a restriction. They can add a hold but cannot clear an existing hold. Explicit Force bypasses these structured condition recommendations; enabled safety gates and restrictions still apply. Rhai script rules remain additional holds on every runnable decision, including Force, and the completed per-zone verdict is the one dispatch reads.

Rhai forecast-rain inputs are () when their evidence is missing. Scripts must handle that absence explicitly; an arithmetic or comparison error holds watering with the script’s name instead of quietly treating missing rain as zero.

Rules run top to bottom in the order they are listed, and the first skip wins. The arrows beside each rule move it earlier or later, which is how you set priority.

This is a different rule from the ladder’s “first matching rule wins” above. Up there the first gate to fire ends the decision and the rest are never reached. Here every enabled, in-scope rule is walked: the skips settle on the first one, which supplies the reason, and any scale factors multiply together before the product is clamped. None of that walk reaches the decision trace. A condition rule that decides a zone shows up only as condition in that zone’s verdict source and reason code; the rules that merely looked, and the ones after the first skip, leave no row behind. To see how a rule reads against the conditions on hand, use the “Would fire now” line beside it in Rule Lab, which re-evaluates live rather than replaying the morning.

Zone soil percent, the 24-hour forecast low, next-four-hour rain, and the weighted three-day rain total can be absent. Comparisons against missing evidence evaluate as unknown, and an expression that still depends on that unknown never fires a condition rule, including through NOT. Protected availability gates decide whether the zone can run. Tomorrow’s rain probability deliberately reads as 100 percent when unreported, matching the engine’s full-weight treatment of a known rain amount; this does not supply a missing amount.

Each rule has its own on/off switch, so silencing one does not mean deleting it. Rules live under conditions.rules in /data/localsky.toml, separate from the thresholds below.

Tunable parameters

All thresholds live under cfg.engine.skip_rules in /data/localsky.toml. The defaults in src/config/schema.rs match the v0.1 hardcoded constants exactly so upgrades preserve verdicts:

[engine.skip_rules]
already_wet_in           = 0.05   # 1.3 mm
rain_now_in_hr           = 0.01   # 0.25 mm/hr
rain_next_4h_skip_in     = 0.10   # 2.5 mm
rain_3day_factor         = 1.5
heat_advisory_temp_f     = 95.0   # 35 C
heat_advisory_humidity_pct = 60.0
heat_advisory_dry_days   = 2
wind_forecast_slack_mph  = 5.0    # 8 km/h
max_wind_mph             = 10.0   # 16 km/h
min_temp_f               = 38.0   # 3.3 C
rain_skip_in             = 0.25   # 6.4 mm
frost_skip_soil_f        = 35.0   # 1.7 C
rain_observed_window_days = 1     # today + N past days of measured rain
soil_quarantine_enabled  = true   # distrust offline / outlier probes
soil_outlier_threshold_pct = 35.0 # pp from sibling median before distrust

Edit via PUT /api/config (the settings UI does this); changes apply on the next engine tick (default 60s).

Replay + audit

Every verdict that fires gets logged to verdict_history (M0005 migration) with the full Inputs blob as inputs_json. Operators investigating a strange decision can replay any historical row through the current engine and compare. cargo test engine::skip_rules includes a regression guard test that runs production verdict history through the engine and asserts 100% verdict + reason match.

Watering restrictions

Many places limit when you may water: a water authority, a council, a water management district, or an HOA may restrict watering to certain days, forbid it during the hottest hours, or cap how long each zone runs. LocalSky’s restriction system encodes those rules and feeds them straight into the skip engine, so the dashboard’s verdict already reflects what you are legally allowed to do.

Restrictions live under Settings, Watering restrictions. Check your local water utility or municipality for the exact rules where you live; LocalSky’s job is to honor them, not to know them.

How a restriction interacts with the engine

Restrictions are evaluated before the weather skip rules. When a restriction blocks watering right now, the engine skips and the verdict reason names the rule (for example, “Watering restriction (HOA summer): today is not an allowed watering day”), so you see the legal block rather than a weather explanation.

Multiple restrictions stack. The engine evaluates every enabled, in-window restriction and the tightest rule wins: if any one of them forbids watering, the run skips. Duration caps accumulate as the smallest cap across all active restrictions. Restrictions also stack with your ordinary skip-rule thresholds (rain, wind, freeze, soil moisture); the overall verdict is the most restrictive of everything that applies.

Address parity

Many jurisdictions split the watering schedule by house number: odd addresses on some days, even addresses on others. Set your parity once, at the top of the page: N/A, Odd, or Even. Each restriction carries a separate allowed-weekday list for odd and for even addresses, and the list matching the parity you set here is the one that binds. (It carries a third list that binds every address regardless; see allowed weekdays.)

Parity matters only for a rule that genuinely depends on it. A rule whose odd and even lists name the same days binds on its own even at N/A, because it never depended on your house number in the first place. Only two things need a parity to decide: two weekday lists that differ, and a date rotation keyed on the address. Those stand aside at N/A rather than guessing, so that half of the rule blocks nothing, while the rest of the same restriction (its every-address days, its forbidden hours, its cap) still applies. The page warns you loudly when an enabled restriction is in that state. Pick Odd or Even and save to enforce the schedule.

The restriction fields

Each restriction has an id (a short snake_case key), a display name (what shows up in the verdict reason), and an enabled toggle. Disabling keeps the entry but stops it being evaluated, which is handy for a seasonal rule you do not want to delete. Beyond those, a restriction is a stack of gates, any of which is inactive when you leave it blank, plus two fields that are not gates at all: the head types this rule spares and the zones it is limited to.

Effective window

When the restriction is active across the calendar. Options:

  • All year: always in effect. Most restrictions use this.
  • Summer (US DST): active from the second Sunday of March to the first Sunday of November (the US daylight-saving window). Some US water districts switch rules with daylight saving.
  • Winter (US standard): the complement of the above.
  • Custom range: an arbitrary start and end (month and day), including wrap-around across the new year (for example November 15 to February 28). A day that overruns its month is clamped to the month end, so “February 30” means “end of February” rather than failing silently.

Outside the US, use Custom range for any seasonal rule; the DST and standard windows follow the US daylight-saving calendar specifically.

Allowed weekdays

The days you are allowed to water, given as three checkbox rows: one for odd addresses, one for even addresses, and one for every address. The odd and even rows are read against your address parity; the every-address row is for a rule that does not depend on your house number and is read whatever your parity is. An empty row is no gate at all (water any day).

Each row that applies is its own gate and every one of them has to pass, so a rule that fills the every-address row and a parity row allows only the days that appear on both. If today fails either, the run skips with “today is not an allowed watering day”.

Use the odd and even rows for a rotation schedule. Use the every-address row for a flat “everyone waters the same two days” rule; that is the row the Two days a week starter template writes. Identical days in the odd and even rows work too, and bind even at N/A parity, but the every-address row says what you mean and does not trip the parity warning.

Date rotation and the 31st

Some districts rotate by the calendar date rather than by the weekday. Date rotation offers four settings: None (the default, no gate), Odd addresses on odd dates, even on even, Everyone on odd dates, and Everyone on even dates. The by-address setting is the one that needs your address parity; at N/A it cannot decide and lets the date through. On a date the rotation forbids, the run skips with “today is not an allowed date for this address”.

Nobody waters on the 31st is a separate checkbox, and its whole reason is arithmetic: 31 is odd and so is the 1st that follows it, so a 31-day month would otherwise hand the odd side two watering days in a row. Districts that rotate by date usually write the exception in. The checkbox is honored on its own as well, with no rotation set, and it is checked before the rotation: on the 31st the skip reason is “the 31st is never a watering day” whatever the rotation would have said.

Days per week

An optional cap on how many days in a Sunday-to-Saturday week may have a run. LocalSky counts the distinct days that already watered this week, today excluded, and once that count reaches the cap the morning run skips with “this week’s allowance of watering days is used up”. Any watering counts toward the week, a morning run or a zone you started by hand alike, because the count comes from your run history rather than from the schedule you planned. Leave the field blank for no cap.

Forbidden hours

A no-watering window, given as a start hour and an end hour (0 to 23 / 24). The window is inclusive of the start hour and exclusive of the end: a 10 to 16 window forbids watering from 10:00 up to 16:00, and watering is allowed again at 16:00. The window may wrap past midnight (for example 22 to 6 forbids the overnight hours). Leave both blank for no time gate.

This is the right gate for “no watering during the heat of the day” rules. Inside the window the run skips with “currently inside the forbidden window”.

Max minutes per zone

An optional hard cap on how long any single zone may run per dispatch. The tightest cap across all active restrictions wins, and that cap is then combined with the zone’s own duration ceiling, so the shortest limit always applies. Unlike the other gates, a cap never causes a skip on its own; it only shortens runs that do go ahead.

Exempt sprinkler types

The heads this rule spares, picked as chips: rotor, spray, MP rotator, drip, bubbler, and other. Many districts exempt drip and other low-volume irrigation from the schedule, so check what your own rules exempt before you set this.

Putting a zone’s Sprinkler type on the exempt list removes that rule’s day and time gates for that zone. The engine then checks every other applicable restriction, safety gate, soil condition, and owner rule before the zone can run. An exempt drip bed can water while a restricted lawn waits, but a freeze or operator hold still stops it. The zone card and dispatch use the same completed verdict. Set each zone’s head type in the zone editor.

A duration cap is not exempted along with the schedule. The tightest cap across the active restrictions is worked out for the yard as a whole and applied to every zone’s planned minutes, exempt heads included.

Only these zones

The zones this rule is limited to, picked as chips. Leaving every chip off is the usual setting and binds the rule to the whole yard; naming zones is what narrows it. Only those named zones inherit that rule’s day and time gates. Every other zone is still checked against the restrictions that apply to it and the rest of the decision ladder.

Starter templates

The page has three one-click starter templates so you do not start from a blank form. Each adds a generic restriction you then edit for your area:

  • No midday watering: forbids 10:00 to 16:00, all year, any day.
  • Two days a week: water Wednesday and Saturday only, plus the same no-midday window.
  • Odd/even address days: odd addresses water Wednesday and Saturday, even addresses Thursday and Sunday (a common parity rotation).

After adding a template, open it with Edit, adjust the days, hours, and dates to match your local rules, then save the restriction. Adding the same template again replaces it rather than duplicating it.

Saving

Adding, saving, or deleting a restriction persists that change immediately. Selecting your address parity also saves immediately. The engine picks up the saved configuration on its next tick. A failed save shows an error; check it before assuming a new restriction is in effect.

Where to read more

Manual schedules

Most of the time you want LocalSky’s smart engine to decide when and how long to water: it reads the weather, settles each zone’s weekly water balance, and fires the zone on the mornings its session spacing allows. Manual schedules are the escape hatch for the cases where you want a zone on a clock instead, a fixed weekday and time you set yourself. You might use one for a drip line on a flower bed the engine does not model well, for a city that mandates a fixed watering window, or just because you prefer a predictable morning run.

Manual schedules live under Settings, Manual schedules. Each schedule fires one zone, on the weekdays you pick, at the start time you set, for a duration you set. Smart irrigation keeps running for every zone that does not have a schedule; manual and smart coexist zone by zone.

How a manual run interacts with the engine

This is the part worth getting right, because it is the whole point of the feature. Every schedule has a mode, and the mode decides what the smart engine does for that zone on the days the schedule fires.

Override (the default)

In Override mode the manual schedule replaces the smart engine for that zone, for that day. When an enabled Override schedule applies to a zone today, the engine zeroes its own planned run for that zone so it does not water on top of your manual run. The engine’s own figures for the zone (throughput, crop coefficient, heat multiplier, capture efficiency) still compute and still show on the zone’s detail panel, but its planned run for that day is zero and the panel reads “Scheduled 0 min”. LocalSky does not show what the smart run would have been. On those days the zone card and the zone detail read ON HOLD and name the schedule. The manual schedule is the only thing that fires.

Use Override when you want full manual control of a zone on the days the schedule covers: on those days the clock you set is exactly what runs, no more, no less (restrictions aside, see below). On the days it does not cover, the zone is a normal smart zone again, and the engine sizes it from the weekly water balance with the schedule’s own water already counted against that week’s target. Cover every weekday, or delete the schedule, if you want the zone to water only on your clock.

Floor

In Floor mode the manual schedule is a minimum, not a replacement. The manual run fires on schedule, and the smart engine may add more runs for that zone if the weekly water balance says the lawn needs more water than the scheduled run delivered. Think of it as “at least this much, plus whatever the engine adds on top.”

Floor is for minimum-coverage patterns: a guaranteed baseline run with the engine topping up during a heat wave.

Two things decide whether the engine ever gets to add on top, and both can close the door completely:

  • A Floor run is watering like any other, so its water counts against the zone’s weekly target. A schedule that already delivers the week’s target leaves the engine nothing to add.
  • A Floor run also resets the session-spacing clock. The engine paces a zone at floor(7 / sessions_per_week) days between sessions, and it measures that from the last run of any kind, including yours. So a schedule that fires as often as the zone’s own session cadence leaves the engine no eligible day at all: a weekly Floor schedule on a 1-session-a-week bed (the default for any zone whose name contains shrub, garden or bed) never opens the gate, and neither does a Monday/Wednesday/Friday schedule on a 2-session-a-week zone. The zone reads ON HOLD, naming the spacing, every day.

If you want a zone fully engine-driven, delete the schedule rather than switching it to Floor. Switch to Floor when you want the scheduled run to stay as a guaranteed minimum and you have raised sessions_per_week enough to leave the engine a day to work with.

The two modes differ only in what they do to smart dispatch. The manual run itself fires identically either way.

Per-zone behavior

A schedule targets exactly one zone (its Zone field), and the mode applies to that zone alone. Override on the back yard does not suppress smart on the front yard. You can mix freely: an Override schedule on one zone, a Floor schedule on another, and pure smart on the rest. You can also have more than one schedule on the same zone (for example a morning and an evening run); each fires on its own clock, and if any of them is an enabled Override for today, smart dispatch for that zone is suppressed for the day.

Days, times, and duration

  • Weekdays. Pick the days the schedule runs. An empty list means it never fires (effectively disabled). Days are independent: a schedule set to Wednesday and Saturday fires on both, with the same time and duration.
  • Start time. A start hour (0 to 23, 24-hour local time) and a start minute (0 to 59). 5 and 0 means 05:00. The dispatcher ticks once a minute, so resolution is one minute and the run fires when the clock reaches the exact hour and minute you set.
  • Duration. How many whole minutes the zone runs per fire, at least 1. This is the planned length; a watering restriction can shorten it (see below), but nothing lengthens it.
  • Enabled. Disable a schedule to keep the entry but stop it being evaluated, the same pattern as restrictions and zones. Handy for a seasonal schedule you do not want to delete.

A schedule fires at most once per day per schedule. If two ticks land on the same minute (clock skew, a leap second), the dispatcher remembers it already fired today and does not double-run.

Holds, safety, and restrictions

Rain delay, vacation pause, hold-all, and active Skip overrides stop manual schedules. Enabled weather safety checks also apply by default.

The Ignore weather safety option is a separate, persistent waiver. Arming it requires confirming the physical consequences, including watering during a freeze or high wind and without live weather evidence. The saved schedule wears an Ignores weather badge, and each dispatch records which gate was bypassed. The waiver never clears an operator hold or a watering restriction.

Manual schedules are not a way around your watering restrictions. Before a manual run dispatches, the engine evaluates the same restriction policy it uses for smart runs. If a restriction blocks watering right now (wrong weekday for your address parity, inside a forbidden-hours window, out of season), the manual dispatch is skipped and a skip row is logged to the runs table with the rule’s reason, exactly like a smart skip. A duration cap from a restriction also applies: if a rule caps zones at 60 minutes and your schedule asks for 90, the run is shortened to 60. The tightest cap across all active restrictions wins.

So a manual schedule sets your intent; restrictions still set the legal floor and ceiling on top of it.

Saving and when it takes effect

Adding, saving, or deleting a schedule persists that change through the config API immediately. The scheduler reads the current saved schedule list on each tick, including the first schedule added to a running install. Schedule edits do not require a restart. A save failure is shown on the page; check it before assuming the schedule changed.

Where to read more

  • Watering restrictions: the rules that gate a manual run before it dispatches, and the caps that shorten it.
  • Irrigation engine: the smart pipeline an Override schedule suppresses and a Floor schedule sits on top of.
  • History and reporting: where a manual run (or its skip row) shows up after it fires, attributed to the schedule.

Grass Species Catalog

LocalSky ships a built-in catalog of 12 grass species + ornamental categories with monthly Kc curves, root zone depths, and MAD percentages. Source: src/engine/species_catalog.rs.

Curves are listed January-December as Northern-Hemisphere anchors; for Southern-Hemisphere locations the engine shifts every curve six months automatically.

ETc for any zone equals ET0 * Kc(species, day-of-year) * heat_multiplier. Picking the right species is the single most impactful zone setting.

Warm-season turfgrasses

These five dominate lawns across warm and subtropical climates worldwide (southern US, Australia, South America, southern Europe, Asia). Kc values cite UF/IFAS Extension publications; the curves are climate-driven, not region-specific.

St. Augustinegrass

  • Citation: UF/IFAS ENH62, “St. Augustinegrass for Florida Lawns”
  • Kc (Jan-Dec): 0.55 / 0.60 / 0.70 / 0.85 / 0.95 / 1.00 / 1.00 / 1.00 / 0.95 / 0.85 / 0.70 / 0.55
  • Root zone depth: ~150 mm (4-6 in; aerated lawns up to 6 in)
  • MAD: 50%
  • Salinity tolerance: ~6 dS/m (ECe at 50% yield)
  • Mow height: 3.5 in (9 cm)
  • Notes: the dominant turf of humid-subtropical regions (US Gulf South; sold as “Buffalo grass” in Australia and New Zealand). Shallow-rooted; prefers deeper, less-frequent watering. Active through the warm season, semi-dormant through the cool season in cooler parts of its range.

Bermudagrass

  • Citation: UF/IFAS ENH19, “Bermudagrass for Florida Lawns”
  • Kc (Jan-Dec): 0.50 / 0.55 / 0.65 / 0.80 / 0.90 / 0.95 / 0.95 / 0.95 / 0.90 / 0.80 / 0.65 / 0.50
  • Root zone depth: ~200 mm (4-8 in; deep on sand)
  • MAD: 50%
  • Salinity tolerance: ~8 dS/m
  • Mow height: 1.5 in (4 cm)
  • Notes: deepest-rooted common turf (sold as “Couch grass” in Australia). Drought-tolerant; can go semi-dormant in heat.

Zoysiagrass

  • Citation: UF/IFAS ENH11, “Zoysiagrass for Florida Lawns”
  • Kc (Jan-Dec): 0.55 / 0.60 / 0.65 / 0.75 / 0.85 / 0.90 / 0.90 / 0.90 / 0.85 / 0.75 / 0.65 / 0.55
  • Root zone depth: ~150 mm
  • MAD: 50%
  • Salinity tolerance: ~7 dS/m
  • Mow height: 2.0 in (5 cm)
  • Notes: slow but dense; tolerates moderate shade; recovers slowly from drought.

Bahiagrass

  • Citation: UF/IFAS ENH6, “Bahiagrass for Florida Lawns”
  • Kc (Jan-Dec): 0.55 / 0.60 / 0.65 / 0.75 / 0.80 / 0.85 / 0.85 / 0.85 / 0.80 / 0.75 / 0.65 / 0.55
  • Root zone depth: ~200 mm
  • MAD: 55%
  • Salinity tolerance: ~4 dS/m
  • Mow height: 3.5 in (9 cm)
  • Notes: drought-tolerant; widely grown pasture grass across the subtropics (native to South America); tolerates low fertility.

Centipedegrass

  • Citation: UF/IFAS ENH8, “Centipedegrass for Florida Lawns”
  • Kc (Jan-Dec): 0.50 / 0.55 / 0.60 / 0.70 / 0.80 / 0.85 / 0.85 / 0.85 / 0.80 / 0.70 / 0.60 / 0.50
  • Root zone depth: ~100 mm (3-5 in; shallow)
  • MAD: 50%
  • Salinity tolerance: ~3 dS/m
  • Mow height: 2.0 in (5 cm)
  • Notes: low-maintenance; iron-chlorotic on high-pH soils.

Cool-season turfgrasses

For cool-temperate and transitional climates (northern US and Canada, the UK and northern Europe, New Zealand, highland regions). Curves drawn from FAO-56 Table 12.

Kentucky Bluegrass

  • Kc (Jan-Dec): 0.55 / 0.60 / 0.75 / 0.85 / 0.85 / 0.80 / 0.78 / 0.80 / 0.85 / 0.80 / 0.65 / 0.55
  • Root zone depth: ~150 mm
  • MAD: 50%
  • Notes: self-repairs via rhizomes; dormant in summer drought without irrigation. Peak ET in spring/fall; summer heat stress dips Kc.

Tall Fescue

  • Kc (Jan-Dec): 0.55 / 0.65 / 0.78 / 0.85 / 0.85 / 0.80 / 0.78 / 0.80 / 0.85 / 0.80 / 0.65 / 0.55
  • Root zone depth: ~250 mm (6-12 in; deepest cool-season)
  • MAD: 55%
  • Notes: deep-rooted; most heat- and drought-tolerant cool-season grass.

Perennial Ryegrass

  • Kc (Jan-Dec): 0.55 / 0.65 / 0.78 / 0.85 / 0.85 / 0.80 / 0.78 / 0.80 / 0.85 / 0.80 / 0.65 / 0.55
  • Root zone depth: ~125 mm
  • MAD: 50%
  • Notes: quick germination; often used to overseed dormant warm-season lawns in mild-winter regions.

Non-turf categories

Ornamental shrubs

  • Citation: UF/IFAS ENH1115, “Florida-Friendly Landscaping”. Kc range consistent with FAO-56 Table 12 ornamental values.
  • Kc: 0.45-0.55 year-round (low seasonal variation)
  • Root zone depth: ~250 mm
  • MAD: 40%
  • Notes: established shrubs use ~half the ET0 of turf. Water deeply + infrequently. Drip preferred.

Vegetable garden

  • Kc: 0.55 / 0.65 / 0.75 / 0.90 / 1.10 / 1.15 / 1.15 / 1.05 / 0.90 / 0.75 / 0.65 / 0.55
  • Root zone depth: ~400 mm
  • MAD: 45%
  • Notes: critical at germination and fruit set. Mulch heavily to cut ET. Curve drawn from FAO-56 Table 12 (vegetables mid-season).

Drip xeriscape

  • Kc: 0.25-0.35 year-round
  • Root zone depth: ~300 mm
  • MAD: 30%
  • Notes: established native plantings on drip. Water only during establishment / drought stress.

Other / unknown

  • Kc: 0.70 flat
  • Root zone depth: 150 mm
  • MAD: 50%
  • Notes: generic placeholder. Override per zone with measured values.

How LocalSky uses these

The catalog drives three things:

  1. ETc per zone per day: ET0 * Kc(species, day-of-year). Day-of-year interpolates linearly between mid-month anchor points with Dec/Jan wrap, so the curve is smooth across new year.
  2. Default root zone depth: feeds TAW (Total Available Water) computation, which together with MAD sets the irrigation trigger threshold. Operators can override via ZoneConfig.root_depth_mm.
  3. Default MAD: sets how dry the soil gets before LocalSky recommends watering. Override via ZoneConfig.mad_pct_override.

Contributing a species

New species PRs welcome. Open a PR against src/engine/species_catalog.rs with:

  • 12 monthly Kc values (mid-month anchors)
  • Default root zone depth (mm)
  • Default MAD percentage
  • A citation: FAO-56 Table 12, a university extension or national agronomy-institute publication (UF/IFAS, AHDB, CSIRO, etc.), or a peer-reviewed paper. We don’t accept “trust me” submissions.

The catalog stores citation and notes strings inline; the dashboard exposes them in the zone-editor’s species picker so operators see provenance at pick time.

Soil Texture Catalog

USDA soil texture classification (developed in the US but used internationally as the standard texture taxonomy; the classes apply to any soil, anywhere). LocalSky uses field capacity (FC), wilting point (WP), available water (AW = FC - WP), and infiltration rate per texture + slope. Source: src/engine/soil_catalog.rs.

Pick texture per zone in the zone editor. If unsure, use the USDA texture triangle: rub moist soil between your fingers and match to the closest class.

Catalog

Water holding per FAO-56 Table 19 (Allen et al., 1998); infiltration per USDA NRCS Part 652 Table 11-3. Every FC and WP below sits inside the range Table 19 publishes for that class, and a test in the engine keeps it that way.

TextureFC (m³/m³)WP (m³/m³)AW (mm/m)Infil flat (mm/hr)Infil 3-5% (mm/hr)Infil >5% (mm/hr)
Sand0.090.0360503525
Loamy sand0.140.0680352518
Sandy loam0.230.10130251812
Loam0.270.1215013107
Silt loam0.320.151701085
Clay loam0.360.20160864
Clay0.380.24140543

How the values map into the engine

Total Available Water (TAW)

TAW_mm = (FC - WP) * root_depth_mm

This is the depth of water the zone can hold between field capacity (fully wet, no gravity drainage) and the wilting point (so dry the plant gives up). St. Augustine on sandy loam at the default 150 mm root depth: TAW = (0.23 - 0.10) * 150 = 19.5 mm. Tall fescue on loam at its 250 mm default depth: TAW = (0.27 - 0.12) * 250 = 37.5 mm, nearly double the buffer.

Readily Available Water (RAW)

RAW_mm = TAW_mm * MAD_pct

MAD (Management Allowed Depletion) comes from the species catalog. RAW is the depletion beyond which the plant starts to stress. It is a reference threshold the tuning report uses when it estimates a watering interval; it is not the live irrigation trigger. See the irrigation engine for what decides watering today.

St. Augustine on sandy loam with default 50% MAD: RAW = 19.5 * 0.50 = 9.75 mm. That is the depletion at which St. Augustine on this soil would start to stress, which is why the tuning report flags an interval much longer than about two days here.

Infiltration rate

Determines whether cycle-and-soak is needed. The three slope bands per row reflect that water runs off faster on a hillside than on a level patch. The cycle-and-soak splitter divides total runtime when the sprinkler’s precipitation rate exceeds infiltration.

Example: MP rotator (14 mm/hr precip) on clay flat (5 mm/hr infiltration). Each minute of runtime delivers 14/60 = 0.23 mm but the soil can only absorb 5/60 = 0.083 mm. Cycling 1 minute on, 4 minutes “soak” wouldn’t actually work because evaporation losses kick in. The splitter computes the maximum continuous on-time at ~(infiltration/precip) * 60 minutes, never shorter than 3 minutes. Each soak gap is derived rather than fixed: the depth left standing when the head shuts off, divided by the infiltration rate, floored at 5, 10, or 15 minutes by texture and at your own engine.soak_minutes (default 5). See cycle-and-soak for a worked example.

Picking the right texture for your zone

Without a soil test, two practical methods:

Ribbon test

  1. Take a handful of moist (not wet) soil. Squeeze into a ball.
  2. Squeeze the ball through your thumb and forefinger to form a ribbon.
  3. Categorize:
    • No ribbon, falls apart: sand or loamy sand
    • Weak ribbon (<2.5 cm before breaking): sandy loam or loam
    • Medium ribbon (2.5-5 cm): clay loam or silt loam
    • Strong ribbon (>5 cm): clay

Jar test

  1. Half-fill a one-litre (quart) jar with soil from the zone’s root depth.
  2. Fill the rest with water + a teaspoon of dish soap.
  3. Shake hard. Set aside.
  4. After 1 minute, mark the sand layer (settles first).
  5. After 2 hours, mark the silt layer.
  6. After 24-48 hours, mark the clay layer (or what hasn’t settled yet).
  7. Use the USDA triangle to classify based on relative thicknesses.

When in doubt

If you genuinely don’t know, sandy loam is the safest guess: it sits mid-triangle and the engine’s math is most forgiving when off by one texture class in either direction (loamy sand or loam).

Contributing a texture

The catalog is a fixed enumeration (USDA’s classification is the standard; “soil 1” and “soil 2” aren’t textures). New entries are not expected. If you need finer-grained soil characterization, override per zone via direct FC/WP/AW values in a future iteration’s ZoneConfig.soil_overrides block.

Further reading

Authentication

LocalSky ships with built-in authentication. New installs create an owner account during the setup wizard; existing installs stay open until you opt in. Identity (accounts, sessions, API tokens) lives in the SQLite database, never in localsky.toml; the TOML carries only policy.

Modes

[auth]
mode = "required"        # "disabled" (default for upgrades) | "required"
session_ttl_days = 30    # rolling browser-session lifetime
trusted_networks = []    # CIDRs that skip login, e.g. ["10.0.0.0/24"]
trusted_proxies = []     # CIDRs of YOUR reverse proxies, e.g. ["172.18.0.0/16"]
# proxy_auth_header = "X-Auth-Request-Email"  # identity header an authenticating proxy stamps
# proxy_auth_allow = ["[email protected]"]      # allowed values; empty = any non-empty value
  • disabled: the pre-auth behavior. The right choice when a reverse proxy already guards access, or on an isolated trusted network.
  • required: the UI redirects to /login; API calls need a session cookie or an API token. New wizard installs that create an owner account get this automatically.
  • trusted_networks: lets the home LAN stay frictionless while VPN/WAN clients must sign in. Each entry is a CIDR matched against the client address. Read the section below before setting this on anything reachable from outside your LAN.
  • trusted_proxies: the CIDRs of your own reverse proxy hops. Set this if (and only if) LocalSky sits behind a proxy; it is what makes LocalSky believe X-Forwarded-For. See below.

X-Forwarded-For, trusted_proxies, and trusted_networks

How LocalSky determines the client address, exactly:

  1. The TCP peer address of the connection is authoritative. That is the address LocalSky uses by default.
  2. X-Forwarded-For is only believed when the peer itself is one of your trusted_proxies. When it is, LocalSky walks the header from the right, skips any hops that are also in trusted_proxies, and takes the first hop that is not a trusted proxy as the client. (The rightmost entries were appended by your own proxy chain; anything to the left of the first untrusted hop is client-supplied and trivially forgeable, so it is ignored.)
  3. If the peer is not in trusted_proxies, X-Forwarded-For is ignored entirely and the peer address wins. A client that reaches the LocalSky port directly therefore cannot spoof its address by sending its own X-Forwarded-For: the header is only honored from a proxy you declared.

That derived client address drives two things: the trusted_networks login bypass and the login/setup rate limiter.

If LocalSky is behind a reverse proxy, set trusted_proxies

Because the peer is authoritative and XFF is ignored unless the peer is a declared proxy, a proxied deployment that does not set trusted_proxies will see every request as coming from the proxy’s own address. The consequences:

  • trusted_networks matches the proxy, not the real client. If the proxy’s address falls inside a trusted_networks CIDR, everyone coming through it skips login; if it does not, nobody gets the bypass. Either way the bypass no longer keys on the real client.
  • The login/setup rate limiter keys on the proxy. All clients share one bucket, so one noisy client (or a distributed brute-force funneled through the proxy) can trip the limit for everyone, and per-client throttling is lost.
  • The privileged surface does not fall back to trusting the proxy’s private IP. In the shipped default (auth Disabled), a bare private-LAN client is normally vouched for config writes, GET /config/raw, backups, and restart without a login. When LocalSky detects a proxy in front but trusted_proxies is empty (a forwarding header is present with no declared proxy), it can no longer tell your LAN from the internet behind that proxy, so it stops vouching the proxy’s private peer on those routes and requires a real credential (loopback and an explicit trusted_networks match still pass). This fails closed: it means an exposed-behind-an-unconfigured-proxy instance will 401 those routes for your own browser too until you either set trusted_proxies or sign in.

So: if you run LocalSky behind a proxy, set trusted_proxies to that proxy’s address/CIDR (for the bundled Docker Compose the proxy is on the Docker bridge, e.g. 172.18.0.0/16; for a host-network proxy use its LAN address). Then XFF is believed from it, and trusted_networks + the rate limiter see the real client again.

Proxy header hygiene

When you set trusted_proxies, your proxy must append (or set) a correct X-Forwarded-For. LocalSky reads the rightmost untrusted hop, so the common nginx idiom is safe here:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

This appends the real peer your proxy observed to the right of whatever the client sent; LocalSky skips your trusted-proxy hops and lands on that appended value, and any client-forged entries sit to its left where they are ignored. (Caddy and Traefik produce a correct chain by default.)

Deployment rules

  • Never expose the LocalSky port directly to the internet with trusted_networks set and trusted_proxies empty/wrong. With trusted_proxies empty the peer is authoritative, so a direct internet client is judged on its real source address (good); but make sure the only route to LocalSky is through your proxy (bind LocalSky to localhost or an internal Docker network, or firewall the port) so a WAN client cannot bypass the proxy and hit a trusted_networks range directly.
  • Do not list a CIDR in trusted_proxies that untrusted clients can originate from. trusted_proxies is “believe XFF from here”; if an attacker can connect from inside that range, they can forge the client address. List only the narrow CIDR(s) your actual proxy uses.
  • On a flat LAN with no proxy, leave trusted_proxies empty. The TCP peer address is used and there is nothing to forge below L3; trusted_networks is fine there as long as the network itself is trusted.

Disabled mode behind a proxy: set trusted_proxies or enable auth

In the default disabled posture LocalSky still guards the privileged surfaces (config read/write, the wizard’s config-write routes, and the backup download/restore) by network position: a request from loopback or a private/RFC1918/ULA address is trusted to reach them without a login, while an internet-public source address is refused. This is the “isolated trusted LAN / behind a guarding proxy” model.

That private-IP trust keys on the same derived client address as everything else, so the proxy caveat above applies here too, and the failure is worse: if a reverse proxy fronts LocalSky and trusted_proxies is not set, every request’s client address is the proxy’s own (RFC1918) address. Because that proxy address is private, every caller now looks LAN-trusted and sails through the privileged gate, including a WAN client the proxy forwarded. The private-IP bypass is effectively defeated.

So if you put any reverse proxy in front of LocalSky, do one of:

  • Set trusted_proxies to your proxy’s address/CIDR (then the gate sees the real client again, and only genuinely-private clients are trusted), or
  • Enable auth.mode = "required" so the privileged surfaces demand a real session or API token regardless of source address.

Either is sufficient; do not rely on the Disabled-mode private-IP trust alone once a proxy is in the path. (A new wizard install that creates an owner account turns on required automatically, which closes this for you.)

Running behind an authenticating reverse proxy

If the proxy in front of LocalSky performs its own login (oauth2-proxy, Authelia, Authentik, Caddy forward_auth, and similar), you can tell LocalSky to accept that proxy’s identity header as proof of an authenticated operator:

[auth]
trusted_proxies = ["172.18.0.0/16"]         # the proxy itself, as above
proxy_auth_header = "X-Auth-Request-Email"  # the header your proxy stamps
proxy_auth_allow = ["[email protected]"]      # optional allow-list
  • proxy_auth_header names the header the proxy stamps on requests it has already authenticated. oauth2-proxy sets X-Auth-Request-Email (with --set-xauthrequest); other gateways have equivalents (Remote-User, Remote-Email). Any header name works; the value just has to identify the signed-in user.
  • The header is honored only when the request’s direct peer is inside trusted_proxies. The check runs against the raw socket address, before any X-Forwarded-For resolution, so a client that reaches the LocalSky port directly and stamps the header itself gains nothing.
  • A vouched caller is treated as an authenticated operator on the privileged surface (config writes, the raw-config read, backups, restart, and the wizard’s config-write routes) in both auth modes. In required mode the rest of the app still wants a session, an API token, or a trusted_networks match; in the common disabled-behind- a-guarding-proxy posture the rest of the app is already open.
  • proxy_auth_allow restricts which identities qualify, compared case-insensitively. Empty means any non-empty value the trusted proxy stamps is accepted, which is right when the proxy itself already limits who can log in.
  • Minting or revoking API tokens still requires a real signed-in owner; the proxy identity never reaches that surface.

Your proxy MUST strip or overwrite this header on incoming client traffic. If a client can smuggle its own X-Auth-Request-Email through the proxy unmodified, that client can impersonate an operator. oauth2-proxy and forward_auth setups overwrite it by construction; verify yours does before setting proxy_auth_header. Combined with the peer check above, both halves must hold: the request must physically come from your proxy, and the header must be the proxy’s own assertion.

What stays public

These paths never require credentials, by design:

PathWhy
/pkg/*, /sw.js, root static assetsCompiled assets; browsers fetch them without credentials
/api/v1/infoPairing probe; carries auth_required so clients know to ask for a token
/login, /api/v1/auth/{status,login,setup}The way in
/ingest/*, /api/v1/ingest/*Weather hardware (Ecowitt consoles, webhooks) cannot authenticate; block at the proxy for internet-facing deployments (details)
/api/v1/healthLiveness for Docker healthchecks; anonymous callers get a trimmed body (no source, controller, or HA detail)
/metricsPrometheus aggregate counters (verdict mix, refresh and degraded counts, controller/cloud error counts, last-fetch latency). No secrets, config, or PII, so a scraper reaches it without credentials. Firewall it at the proxy if you do not want it public
/docs/*The bundled handbook, so in-app help and the setup guide work pre-login and on fresh installs. Static pages, no secrets
/setup + wizard APIsOnly until the first account exists

Accounts

One owner account for now. Create it in the wizard’s Account step, or later under Settings, then Account. Passwords are stored as argon2id hashes. Sign-in attempts are rate limited per client address.

API tokens (integrations)

Integrations authenticate with long-lived API tokens sent as Authorization: Bearer lsk_...:

  1. In LocalSky: Settings, then Account, then Create token (name it, e.g. home-assistant).
  2. The plaintext is shown exactly once; store it where the integration asks for it. Only a hash is kept server-side.
  3. Revoke any token from the same screen; the Home Assistant integration starts its reauthentication flow automatically on the next 401.

Minting a token requires an authenticated owner session, even in Disabled mode. Unlike the config/backup surfaces, the token-admin endpoints are gated on a real owner identity, not on network position: a trusted-LAN or loopback caller is not enough. So on a Disabled-mode install the Account page’s Create token needs you to sign in at /login first (with the owner account you created in the wizard, or under Settings, then Account). If you have never created an owner account, create one before minting tokens; with zero accounts a token cannot be attributed and the request is refused.

SSE streams accept ?access_token=lsk_... as a query parameter for clients that cannot set headers. It is honored only on paths ending in /stream and ignored everywhere else (the browser EventSource sends the session cookie automatically, so this is only for external consumers).

Lockout recovery

If you lose the owner password, stop the container and delete the users rows from the database, then restart and re-run account creation:

sqlite3 /path/to/data/irrigation.db "DELETE FROM auth_sessions; DELETE FROM api_tokens; DELETE FROM users;"

Physical access to the data volume is the trust anchor, the same as Home Assistant’s.

Reverse proxy and HTTPS

LocalSky listens on plain HTTP (default :8090). On a trusted LAN with built-in auth enabled that is a reasonable place to stop. To reach it from the internet, put a TLS reverse proxy in front and let it terminate HTTPS.

Three things matter for any proxy:

  1. Pass X-Forwarded-Proto: https so LocalSky marks its session cookie Secure.
  2. Overwrite (never append to) X-Forwarded-For with the real client address. LocalSky reads the first hop of that header for auth.trusted_networks and login rate limiting, and it has no trusted-proxy list, so an appended header leaves a client-forged address in the position LocalSky trusts. See X-Forwarded-For and trusted networks.
  3. Server-Sent Events (/api/v1/stream, /api/v1/irrigation/stream, /api/v1/forecast/stream, plus their legacy /api/* aliases) are long-lived responses: disable buffering and give them a long (or no) read timeout.

What to expose

Built-in auth gates most of the app, but a few paths are public by design. For an internet-facing deployment, narrow them at the proxy:

  • Block /ingest/* and /api/v1/ingest/* from the internet. These receive sensor data from hardware that cannot authenticate (Ecowitt consoles, webhook devices), so they are exempt from auth. Anyone who can POST to them can feed LocalSky fabricated weather, and fabricated weather steers irrigation decisions. Your weather hardware is on your LAN; the internet has no business reaching these paths.
  • Consider blocking /setup and /api/v1/wizard/* until setup is done. The setup wizard (pages and APIs) is public until the first account exists, so a brand-new instance exposed before you finish the wizard can be configured by whoever finds it first. Either complete the wizard before exposing the instance, or block these paths at the proxy until you have created the owner account (after that, LocalSky locks them itself).
  • Consider blocking /metrics from the internet. The Prometheus exposition endpoint is public like /api/v1/health: it carries only aggregate operational counters (verdict mix, refresh and degraded counts, controller/cloud error counts, last-fetch latency), no secrets or PII. That is safe to leave open on a LAN, but if you do not want the numbers public, firewall or 403 /metrics at the proxy (let your monitoring host reach it directly).
  • Keep /pkg/* and /sw.js reachable without credentials. These hydration assets are fetched by the browser without cookies; if a proxy-side auth layer intercepts them, the app shell breaks (see the warnings in each proxy section below).

Everything else (dashboard pages, the API, uploaded photos) is covered by LocalSky’s own auth when [auth] mode = "required". If you run with auth disabled, the proxy is your only gate; in that case put proxy-side auth in front of everything except /pkg/*, /sw.js, and (if hardware posts from outside) the ingest paths.

Caddy

localsky.example.com {
    reverse_proxy 127.0.0.1:8090 {
        flush_interval -1   # stream SSE unbuffered
    }
}

Caddy sets the forwarding headers and provisions certificates automatically, and (since 2.5) ignores forwarded headers from untrusted clients, so the X-Forwarded-For LocalSky sees is the real client address. If you also gate with Caddy-side auth (forward_auth, OAuth plugins), exempt /pkg/* and /sw.js: hydration assets are fetched without credentials and a redirect there breaks the app shell.

To block the ingest receivers from the internet with Caddy:

localsky.example.com {
    @ingest path /ingest/* /api/v1/ingest/*
    respond @ingest 403

    reverse_proxy 127.0.0.1:8090 {
        flush_interval -1
    }
}

nginx

server {
    listen 443 ssl;
    server_name localsky.example.com;
    # ssl_certificate ...; ssl_certificate_key ...;

    # Block unauthenticated receivers from the internet.
    location ~ ^/(ingest|api/v1/ingest)/ {
        return 403;
    }

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # SSE: no buffering, no read timeout.
    location ~ /stream$ {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 24h;
    }
}

Note X-Forwarded-For $remote_addr, not $proxy_add_x_forwarded_for. The latter appends to whatever X-Forwarded-For the client sent, and LocalSky reads the first (client-controlled) hop, which would let an internet client spoof a trusted_networks address and bypass login. $remote_addr replaces the header with the address nginx actually saw.

If nginx itself sits behind another proxy you control (e.g. a Cloudflare tunnel), use the real_ip module to recover the true client address first, and still send LocalSky a single-value header.

Traefik (Docker labels)

services:
  localsky:
    # ... your localsky service ...
    labels:
      - traefik.enable=true
      - traefik.http.routers.localsky.rule=Host(`localsky.example.com`)
      - traefik.http.routers.localsky.entrypoints=websecure
      - traefik.http.routers.localsky.tls.certresolver=letsencrypt
      - traefik.http.services.localsky.loadbalancer.server.port=8090

Traefik streams responses by default and, unless you opt in to forwardedHeaders.insecure or trustedIPs, discards forwarded headers from untrusted clients and sets its own, which is what LocalSky needs.

If you add a Traefik auth middleware (forwardAuth, basicAuth, OAuth) in front of LocalSky, exempt /pkg/* and /sw.js from it (a higher-priority router for those path prefixes without the middleware). Hydration assets are fetched without credentials; gating them breaks the app shell exactly as it does with Caddy or nginx.

Home Assistant integration through a proxy

The HACS integration talks to whatever host/port you pair it with. On the LAN, pair it straight to :8090 (with an API token when auth is required) and keep the proxy for browsers; nothing else is needed.

Upgrading LocalSky

LocalSky ships as a single Docker image. Upgrading means pulling a newer image and recreating the container. Everything that matters lives in /data (your config file and the SQLite database), so the container itself is disposable: stop it, remove it, start a new one on the same volume, and you are back where you were, on the new version.

Back up first

Before any upgrade, download a backup bundle. It takes one click (Settings -> Advanced -> Download backup) or one command:

curl -fL -o localsky-backup.tar.gz http://localhost:8090/api/v1/backup

If you enabled authentication, add -H "Authorization: Bearer lsk_..." with an API token. See Backup, restore, and recovery for everything the bundle contains and how to restore it. A pre-upgrade backup is also your downgrade path, so do not skip it.

Choosing a tag

The image is published at ghcr.io/silenthooligan/localsky:

  • Pinned version (ghcr.io/silenthooligan/localsky:v0.7.0): you decide exactly when to move and what release notes apply. Recommended while LocalSky is pre-1.0.
  • :latest: always points at the newest release. Convenient, but a routine docker compose pull can move you across versions without you reading the release notes first.

Either way, read the release notes on GitHub before upgrading. Releases that change the database or config schema say so explicitly.

0.7.0 integration lockstep. As of 0.7.0 the Home Assistant integration ships in version lockstep with the app. The 0.7.0 integration requires the app at 0.7.0 or newer (API 1.12.0 or newer); an older app build cannot pair the new integration until the app is upgraded. Upgrade the app first, then the integration.

The upgrade

With plain docker run (matching the install command from Quick start):

docker pull ghcr.io/silenthooligan/localsky:latest
docker stop localsky && docker rm localsky
docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -v /opt/localsky/data:/data \
  ghcr.io/silenthooligan/localsky:latest

With Docker Compose:

docker compose pull
docker compose up -d

Removing the container does not touch /data. Your config, run history, sensor history, and login accounts all survive the recreate.

Auto-updaters (Watchtower, Diun notifications, Renovate on a pinned compose file) work fine with this image. Pair them with a scheduled backup if you let them act unattended.

What happens on first boot after an upgrade

  1. Database migrations run. LocalSky keeps a chain of numbered SQLite migrations (22 of them as of this release) and records each applied one in a schema_migrations table. On boot it applies only the ones your database has not seen yet. Each migration runs inside a single transaction, so a failure rolls back cleanly rather than leaving a half-migrated database. Skipping releases is fine: the chain applies in order, however many versions you jumped.
  2. Config migrations run, once. /data/localsky.toml carries a schema_version field (currently 2). Each release’s config migrations are an ordered list applied exactly once; the ledger beside the config, /data/localsky.ledger.toml, records which have run, and the migrated document is written back so the next boot finds nothing to do. Fields added by newer releases are filled with documented defaults when missing from an older file, and unknown leftover fields are ignored, so old configs keep loading. 0.9.0’s migration moves the server-owned records (which forecast authorities were seeded, which Home Assistant helpers the 0.7.22 migration recorded) out of the document and into the ledger, where no settings save, raw edit, rollback or restore can drop them.
  3. The app comes up at the same address with the same data, zones, and history.

No manual migration steps. If a migration fails, the error appears in docker logs localsky with the migration version that failed.

Ownership is handled for you. LocalSky runs as a non-root user (uid 10001 by default) and the container fixes the ownership of /data to that user at startup. Upgrading from an older version that ran as root (and left root-owned files in the volume) needs no manual chown; the only requirement is that /data stays writable (not mounted read-only). If you front LocalSky with a reverse proxy, set trusted_proxies so it sees the real client IP (see Authentication).

PUID / PGID are now honored (they were previously ignored). The container drops to PUID:PGID, defaulting to 10001:10001. Two things to know when upgrading:

  • If your volume is owned by 10001 (the default), leave PUID/PGID unset or set them to 10001 so nothing re-chowns.
  • If you carried a stale PUID=1000 from an old .env, the container will now run as 1000 and re-chown /data to 1000 on first boot. That is harmless but one-time; set it to match your volume’s owner if you want to avoid the re-chown.

NFS exports that refuse ownership changes (Synology / QNAP with root_squash or “map all users”) can’t be chowned to an arbitrary uid. The container detects that /data still isn’t writable as the target uid and falls back to running as the volume’s actual owner so writes succeed, instead of erroring. It will not fall back to root: if the only writable owner is root, it logs an error and refuses, since running the app as root would defeat the non-root model. Pin a concrete uid:gid with PUID/PGID (matching what your NAS maps the share to) for full control. See Troubleshooting -> Wizard cannot save.

Downgrading and rollback

Rolling back the image is the same recreate dance with an older tag:

docker stop localsky && docker rm localsky
docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -v /opt/localsky/data:/data \
  ghcr.io/silenthooligan/localsky:v0.7.0

Two things to know:

  • Database migrations are not reversed. An older binary simply ignores migration entries it does not know about. That often works, but if the release you are leaving changed table shapes, the older code may misread them. The supported downgrade path is to restore the backup you took before upgrading (see restore).
  • A config from the future is refused. If a newer release ever bumps schema_version above what the running binary supports, the loader refuses it with refusing to load a config newer than this binary and LocalSky boots as if unconfigured rather than guessing. Restore the pre-upgrade localsky.toml from your backup (or re-upgrade). As of this release schema_version is still 1, so this cannot bite you yet.

There is also a config rollback path that is independent of the image tag. LocalSky snapshots localsky.toml on every save (newest 20 kept), so you can list and restore an earlier config without touching the container:

curl http://localhost:8090/api/v1/config/snapshots
curl -X POST -H 'Content-Type: application/json' \
    -d '{"ts": <snapshot ts>}' \
    http://localhost:8090/api/v1/config/rollback

This rolls back the config only, not the database or the image. For a full downgrade, restore the pre-upgrade backup bundle. See the configuration reference for details.

Update notifications

LocalSky never updates itself and phones nowhere by default. Two opt-in ways to hear about new releases:

Server-side check. Add to /data/localsky.toml and restart the container:

[updates]
check_enabled = true   # default: false

When enabled, LocalSky polls the project version manifest at localsky.io/latest.json about once a day (a plain GET; the running version travels in the User-Agent, nothing per-install) and serves the result at:

curl http://localhost:8090/api/v1/updates
{
  "current": "0.7.0",
  "latest": "v0.7.1",
  "update_available": true,
  "release_url": "https://github.com/silenthooligan/localsky/releases/tag/v0.7.1",
  "checked_at_epoch": 1765432100,
  "check_enabled": true
}

The first check happens about a minute after boot; until then latest is null. Wire update_available into whatever notifies you (Home Assistant REST sensor, Uptime Kuma keyword, a cron + curl).

Per-device check. Settings -> Advanced -> “Check for new LocalSky releases” makes your browser (not the server) fetch localsky.io/latest.json, at most once per 24 hours, and shows the result inline. It is stored per device and discloses that device’s IP to the localsky.io server, which the toggle’s help text says outright.

Upgrading from v0.1

v0.1 installs are adopted in place; point the v0.2 container at the same /data:

  • An existing irrigation.db that predates the migration runner is detected on first boot. The legacy runs table is rebuilt into the current schema with every historical row preserved (your watering history carries forward), and existing web push subscriptions are kept as-is.
  • /data/localsky.toml, if the wizard already wrote one, loads and migrates on the first boot (schema_version = 1 becomes 2, with the records it carried moved to localsky.ledger.toml).
  • New v0.2 surfaces (authentication, the /api/v1/* API prefix, backup endpoints) start in their defaults: auth stays disabled until you create an owner account, and the old bare /api/* paths still work for existing clients.

Take a copy of /data before the first v0.2 boot anyway. The runs-table rebuild is one-way, and a 30-second tar czf localsky-v01.tar.gz -C /opt/localsky data is cheap insurance.

Backup, restore, and recovery

Everything LocalSky knows lives in the /data directory you mounted at install time. Back that up and you can rebuild a working instance on any machine in minutes.

What is in /data

FileWhat it holds
localsky.tomlYour entire configuration: location, sources, controllers, zones, schedules, restrictions, notification channels
localsky.ledger.tomlLocalSky’s own record beside the config: which config migrations have run, which forecast authorities it seeded, the Home Assistant helper migration. Not for editing
irrigation.dbThe SQLite database: run history, sensor history, verdict history, decision traces, web push subscriptions, and (when auth is enabled) accounts, sessions, and API tokens
irrigation.db-wal, irrigation.db-shmSQLite write-ahead-log sidecars; present while the container runs
*.restore, irrigation.db.restore-state.jsonPending restore files and the durable record that identifies a complete staged set; preserve them if startup reports an interrupted restore
localsky.toml.restore-hot-apply.pendingPresent during a config-only restore; a leftover means the apply did not finish and startup requires recovery
*.pre-restore.<transaction>Prior live files retained during restore activation, including the old database’s journal sidecars
localsky.toml.draftFirst-run wizard progress, if you saved mid-wizard; deleted when the wizard finishes
instance-idA stable random identity used for mDNS and Home Assistant pairing
site/photos/Zone photos uploaded through the zone editor

The database runs in WAL mode, so SQLite can recover interrupted database transactions. Replacing the config, ledger, and database is a separate operation: an interrupted restore refuses startup until you recover a complete set.

LocalSky can produce a consistent backup bundle while running: a .tar.gz containing localsky.toml, localsky.ledger.toml (the server-owned migration and seeding record beside it), a point-in-time copy of irrigation.db (made with SQLite’s VACUUM INTO, safe against concurrent writes), and a small manifest.json recording the version and timestamp.

From the UI: Settings -> Advanced -> Backup and restore -> Download backup.

From the command line:

curl -fL -OJ http://localhost:8090/api/v1/backup
# saves localsky-backup-<version>-<timestamp>.tar.gz

If authentication is enabled ([auth] mode = "required"), pass an API token:

curl -fL -OJ -H "Authorization: Bearer lsk_yourtoken" \
  http://localhost:8090/api/v1/backup

That curl line drops straight into cron for nightly backups. Keep a few generations and store them off the machine that runs LocalSky.

The bundle contains real secrets. So that it restores onto a fresh machine without you re-typing everything, localsky.toml is included full fidelity: your Home Assistant token, MQTT and SMTP passwords, OpenSprinkler password hash, LLM API key, and any webhook URLs are all in the file. The download endpoint is privileged (only an authenticated session, an API token, or a trusted-network/loopback caller can fetch it, even when auth is set to disabled), but the resulting .tar.gz is a credential once it leaves the box. Store it somewhere secure and encrypted, and treat it like a password. (The on-screen config views, by contrast, redact secrets.)

Deliberately not in the bundle:

  • The web push VAPID private key (wherever VAPID_PRIVATE_KEY_PATH points). A casually shared backup should not leak a signing key; copy it separately if you use web push.
  • instance-id. Restoring a bundle onto new hardware mints a new identity on purpose.
  • Zone photos (/data/site/photos/). Copy that directory yourself if the photos matter to you.

Offline alternative

No API needed; plain files work too.

While running (WAL mode makes a SQLite-aware copy safe):

# Bind mount, as in the install docs:
sqlite3 /opt/localsky/data/irrigation.db \
  ".backup '/backup/localsky/irrigation-$(date +%F).db'"
cp /opt/localsky/data/localsky.toml /backup/localsky/localsky-$(date +%F).toml
cp /opt/localsky/data/localsky.ledger.toml /backup/localsky/localsky-$(date +%F).ledger.toml

# Named volume instead? The files live under Docker's volume root:
sqlite3 /var/lib/docker/volumes/localsky-data/_data/irrigation.db \
  ".backup '/backup/localsky/irrigation-$(date +%F).db'"

Cold copy (simplest, brief downtime):

docker stop localsky
tar czf localsky-backup-$(date +%F).tar.gz -C /opt/localsky data
docker start localsky

A cold tar of the whole directory captures everything, including the wizard draft, instance id, and photos.

Scheduled backups (automatic)

The best backup is the one you do not have to remember. LocalSky can write a bundle to a local directory on an interval and keep the newest few, off by default and enabled with one environment variable:

docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -v /opt/localsky/data:/data \
  -e LOCALSKY_AUTO_BACKUP_HOURS=24 \    # interval in hours; unset or 0 disables
  ghcr.io/silenthooligan/localsky:latest

Bundles are written as localsky-backup-<epoch>.tar.gz in LOCALSKY_BACKUP_DIR (default /data/backups, so they live inside your mounted volume), in the exact same format as the API bundle above, so they restore through the same flow. Two more optional knobs:

  • LOCALSKY_BACKUP_DIR: where bundles are written (default /data/backups).
  • LOCALSKY_BACKUP_KEEP: how many newest bundles to retain; older ones are pruned (default 7).

These bundles contain real secrets (like every backup), and by default land inside /data, so keep the volume protected. For off-box durability, point LOCALSKY_BACKUP_DIR at a mounted path that is itself backed up, or copy the directory out on your own schedule. A scheduled backup you have never restored is still only hope: run through Test your restore once.

Restoring

From a backup bundle

From the UI: Settings -> Advanced -> Backup and restore -> Restore from bundle, then pick the .tar.gz.

From the command line:

curl -f -X POST \
  -F [email protected] \
  http://localhost:8090/api/v1/backup/restore
docker restart localsky

What the restore does, exactly:

  • Validate every uploaded part before changing live or staged files. Config and ledger must parse and the config must be supported by this release. A database must pass SQLite integrity checks, match a supported LocalSky migration history and schema, and successfully run pending migrations on a disposable copy. Malformed uploads and unsupported or inconsistent databases are rejected; the original uploaded database bytes stay unchanged.
  • Stage database-bearing restores for restart. The supplied config, ledger, and database become localsky.toml.restore, localsky.ledger.toml.restore, and irrigation.db.restore. A synced irrigation.db.restore-state.json records their paths, hashes, and which optional parts are absent. It moves from publishing to ready only after the complete set is published. A DB-only restore replaces any earlier pending set without inheriting its stale config or ledger stages.
  • Hold new watering. Accepting a database-bearing restore, including DB-only, latches the shared restart hold before staging changes. Manual runs and overrides cannot bypass it, and another settings save cannot clear it. Already-running controller timers may finish; the hold does not stop an active valve. Use Stop if you need to stop current watering. The response reports restart_required and its reasons.
  • Verify and activate at boot. Before opening the database or registering controllers, LocalSky verifies the complete ready set and its compatibility again, then records applying. Prior config, ledger, and database files are retained as .pre-restore.<transaction> copies; the old database’s WAL, SHM and rollback-journal files move with its recovery copy. The marker clears only after all replacements succeed, the database opens, and the config loads. Any failure refuses startup.

These are separate file replacements, not an atomic multi-file restore. Ordinary staging errors attempt to restore the previous stages. Power loss, process termination, or an uncertain rollback can leave a partial set; the durable marker prevents a new process from running against it. An interrupted publishing or applying marker, mismatched files, or an older release’s unmarked .restore files require manual recovery.

A config-only upload applies through the normal config and runtime path. Threshold-only changes can take effect immediately. Changes to startup connections or deployment settings require restart and hold new watering. During the apply, localsky.toml.restore-hot-apply.pending protects the config/ledger pair; it clears only after saving both and publishing the runtime change. A failed or interrupted apply leaves recovery required rather than claiming the previous config was restored.

You can also restore pieces individually: -F [email protected] applies a config, while -F [email protected] stages only a database. Read the response’s restart_required and restart_reasons fields after either request. A disconnected HTTP client does not cancel an already accepted restore; check its state before submitting another.

From plain file copies

docker stop localsky
cp /backup/localsky/irrigation-2026-06-01.db /opt/localsky/data/irrigation.db
rm -f /opt/localsky/data/irrigation.db-wal /opt/localsky/data/irrigation.db-shm /opt/localsky/data/irrigation.db-journal
cp /backup/localsky/localsky-2026-06-01.toml /opt/localsky/data/localsky.toml
cp /backup/localsky/localsky-2026-06-01.ledger.toml /opt/localsky/data/localsky.ledger.toml
docker start localsky

This example assumes no interrupted restore or pending stages; otherwise follow manual recovery first. Keep the config and its ledger together. Remove stale -wal, -shm and -journal sidecars only when replacing the database with a self-contained SQLite backup; preserve the original files elsewhere first. The restore endpoint checks whether an older database can migrate to the current release before accepting it. Plain file copying bypasses that upload validation.

Test your restore

A restore test needs a separate data directory and an instance that cannot reach your controllers. Demo mode rejects privileged restore requests, so use a normal instance with networking disabled. The following test exposes no host port; access it through docker exec. Select the image version you intend to restore into and provide any environment variables referenced by your config.

restore_test_dir=$(mktemp -d /tmp/localsky-restore-test.XXXXXX)
docker run -d --name localsky-test --network none \
  -v "$restore_test_dir:/data" \
  -e LOCALSKY_SMART_DRY_RUN=1 \
  -e LLM_ADVISOR_DISABLED=1 \
  ghcr.io/silenthooligan/localsky:VERSION_YOU_ARE_TESTING

# Wait until the fresh process responds, then upload from inside its network namespace:
docker exec localsky-test curl -f http://127.0.0.1:8090/api/v1/health
docker cp localsky-backup-....tar.gz localsky-test:/tmp/restore-bundle.tar.gz
docker exec localsky-test curl -f -X POST \
  -F bundle=@/tmp/restore-bundle.tar.gz \
  http://127.0.0.1:8090/api/v1/backup/restore
docker restart localsky-test
docker logs localsky-test
docker exec localsky-test curl -f http://127.0.0.1:8090/api/v1/health

Check the startup log for successful restore activation, then use docker exec and the read-only config, irrigation snapshot, and history endpoints to check your zones, settings, and history. After restart, authentication follows the restored config and database; provide a valid restored API token if required. Unreachable sources and controllers are expected with networking disabled. This proves restore and loading, not physical device operation. Keep the network disabled and do not issue run actions. Remove the test container when finished; its isolated data directory remains available for inspection:

docker rm -f localsky-test
printf 'Test data retained at %s\n' "$restore_test_dir"

Recovery patterns

“I broke my config and the UI still loads”

Settings -> Advanced -> Raw TOML editor edits /data/localsky.toml directly and validates before saving. Or push a known-good config file without restoring the database:

curl -f -X POST -F [email protected] \
  http://localhost:8090/api/v1/backup/restore

Config saves retain the previous document in the config directory’s snapshots/ folder, keeping the newest 20. List them at GET /api/v1/config/snapshots or GET /api/v1/backup/snapshots, and restore one with POST /api/v1/config/rollback and JSON {"ts": <snapshot timestamp>}. Rollback uses the normal validation and runtime apply path; inspect its restart requirement. These config snapshots do not replace database backups.

“A restore was interrupted”

If the log reports an incomplete restore, repeated restarts will not finish or undo it automatically. LocalSky refuses to register controllers or start schedulers against an uncertain set. Recover with the process stopped:

  1. Preserve the complete data directory, startup error, marker, .restore stages, .pre-restore.<transaction> files and any .restore.previous-<pid>-<sequence> copies. For a config-only failure, preserve localsky.toml.restore-hot-apply.pending too. Do not delete a marker merely to bypass the startup check.
  2. Choose one complete, verified recovery set: a known-good backup, or the matching pre-restore files. A partially activated restore can contain a mixture of old and new live files; do not select each file independently by its timestamp. A database recovery copy may depend on the WAL journal archived beside it.
  3. Restore the selected config, its ledger, and database together while LocalSky is stopped. Keep that database’s own journal files when recovering a WAL-based copy; exclude unrelated journals when restoring a self-contained backup. Validate the config and database with the intended LocalSky version in an isolated instance before returning it to service.
  4. Only after the selected live set is complete and verified, archive the obsolete marker and pending stages outside their watched paths. Do not fabricate a ready marker or edit its hashes to make a partial set pass. Unmarked .restore files left by an older release need this same deliberate recovery.
  5. Start LocalSky and check the startup log, health, configuration, zones and history. A fresh process clears the runtime restart hold; successful loading and the expected bindings still need verification before resuming watering.

“Nothing loads at all”

Edit the file from the host (bind mount: /opt/localsky/data/localsky.toml) or via the container:

docker exec localsky cat /data/localsky.toml > /tmp/broken.toml
# fix /tmp/broken.toml in your editor
docker cp /tmp/broken.toml localsky:/data/localsky.toml
docker restart localsky

Worst case, move the file aside and rerun the first-run wizard; the database (and all history) is untouched by config problems.

“The database is corrupted”

Crashes mid-write are handled automatically by WAL recovery. For real filesystem-level corruption:

docker stop localsky
mv /opt/localsky/data/irrigation.db /opt/localsky/data/irrigation.db.bad
rm -f /opt/localsky/data/irrigation.db-wal /opt/localsky/data/irrigation.db-shm
docker start localsky

Boot creates a fresh database via the migration chain. Your config, zones, sources, and controllers are all preserved (they live in localsky.toml); run history starts over unless you restore a database backup instead.

“I want to move to a new machine”

# Old host
docker stop localsky
tar czf localsky-move.tar.gz -C /opt/localsky data

# New host
mkdir -p /opt/localsky
tar xzf localsky-move.tar.gz -C /opt/localsky
docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -v /opt/localsky/data:/data \
  ghcr.io/silenthooligan/localsky:latest

A full directory copy carries everything, identity included, so Home Assistant pairings and push subscriptions follow you. If you used the API bundle instead, the new host gets a fresh identity and excludes the VAPID key by design: re-pair the HACS integration and re-enable push notifications on your devices afterward.

Advanced settings

The Advanced page (Settings, Advanced) is for debug visibility, rollback, and backup. Nothing here changes how the engine decides to water; these controls only expose what is already happening, or let you recover a previous state. Most of the toggles are per-device (stored in this browser’s local storage), so turning one on here does not affect anyone else’s view.

Nerd mode

Nerd mode shows the raw inputs behind the verdict on the irrigation page. With it on, the forecast panel adds its forecast intelligence block, today’s and tomorrow’s reference evapotranspiration (ET0) and rain with the heat multiplier, the advisory soil model, and today’s temperature range, peak wind and mean humidity, and the skip check shows its full breakdown rather than the summary.

It is the right setting when you want to understand or audit a decision, or when you are tuning species and soil settings and want to watch the math respond. It is per-device and persisted, so you can leave it on for your own browser without cluttering a shared dashboard.

Kiosk mode

Kiosk mode hides destructive controls on this device. With it on, the device cannot trigger any irrigation action: no running a zone, no stop-all, no threshold edits, no pause toggles. Status, history, and all the read-only views stay fully visible.

This is for shared and public-facing screens: a wall tablet, a family device, a kiosk in a lobby. It is per-device, so the screen on the wall can be locked down while your own browser keeps full control.

Source freshness

Source freshness now lives in the unified device list under Settings, Devices. Every source you have configured appears there exactly once, whatever its kind: local weather stations, cloud services, the irrigation refresher, and the forecast source. Each entry shows its live status with a colored pill (fresh, stale, waiting, or offline), when it last reported, the sensors it provides, and an enable/disable toggle so you can take a source out of rotation without deleting it.

Staleness is judged against each source’s own expected cadence, so a forecast that polls every 30 minutes and a station that reports every few seconds are each graded on their own clock. Use this to confirm a source is alive before chasing a verdict you do not understand.

Cloud services you have not enabled yet do not clutter the configured list. They appear separately as “coverage you can add”, so you can see at a glance which extra data sources are available to turn on.

Update check

An opt-in check for new LocalSky releases. Off by default. When you turn it on, this device asks the project’s version manifest at localsky.io/latest.json for the newest release at most once per day and shows it below the toggle, flagging when a newer version is available with a link to the release notes. The page is explicit about the trade: that request reveals this device’s IP address to the localsky.io server, and the running version travels in the request’s User-Agent so the maintainer can see aggregate version adoption. No per-install identifier or config data is sent. That outbound contact is why it is opt-in. Per-device and persisted.

Demo mode

A read-only status line showing whether the deployment is running in demo mode. When active, all controller actions are recorded but never fired and the weather data is simulated. This is not a toggle on this page: demo mode is enabled with the LOCALSKY_DEMO=1 container environment variable, which seeds a demo config with features.demo_mode = true. The line just tells you which mode you are in.

Configuration history and rollback

Every time the configuration is saved, LocalSky snapshots the previous version before writing. The Configuration history panel lists the most recent versions (up to 20), each with its version number, when it was applied, and an optional note.

If a change goes wrong, you can roll back to any listed version. The rollback is performed through the API (POST /api/config/rollback?to=<version>); the panel shows you which versions are available to target. The first save records version 1, so a brand-new install starts with an empty list.

Backup and restore

A full backup in one bundle. Download backup produces a single archive holding your configuration and the entire history database (runs, sensor readings, and decisions). The VAPID push key and the instance identity are deliberately left out, so a backup is safe to copy between installs without cloning a deployment’s identity.

Restore from bundle uploads a backup to apply. Because a restore replaces both the current configuration and the history database, it asks you to confirm before doing anything, and the picked file alone never triggers it. A configuration restore applies on the next engine tick; a database restore takes effect at the next container restart.

Raw TOML editor

A direct editor for /data/localsky.toml. It loads the live config as text, lets you edit it, and validates on save (TOML parse plus the schema invariants) before writing. This is the escape hatch for adding sources, controllers, or zones from a template you already have, bypassing the wizard entirely. Unlike the JSON config API, the raw file shows secrets in place, so treat the editor accordingly. The container loads the new config on its next restart.

Where to read more

Troubleshooting

This page is keyed by symptom. Find the thing that looks wrong, follow the steps. When in doubt, start with the first section: almost every problem shows its face in the logs or the health endpoint before it shows anywhere else.

Logs and health first

Read the logs

docker logs -f localsky

Log verbosity is controlled by the standard RUST_LOG environment variable (the server uses tracing with an env filter; if RUST_LOG is unset it defaults to info). To get engine, source, and controller detail without drowning in HTTP transport noise:

docker run ... -e RUST_LOG=info,localsky=debug ...

Restart the container after changing it.

Ask the health endpoint

curl -s http://localhost:8090/api/v1/health | jq

What the fields mean:

  • status is a three-step ladder:
    • wizard: no config file exists yet. Visit /setup.
    • ok: config loaded and every enabled source is reporting.
    • degraded: the config file exists but failed to load, or at least one enabled source is offline.
  • sources[]: one entry per configured source with last_seen_epoch, stale_for_s, and a status of fresh, stale, or offline. For live sources (stations, soil sensors) the windows are: fresh under 5 minutes, stale from 5 minutes to 1 hour, offline past 1 hour (or never seen). Polled forecast sources (Open-Meteo, NWS, OpenWeather, Pirate Weather, MET Norway, Netatmo) refresh on a roughly 30 minute cadence, so they get wider windows: fresh under 65 minutes, offline past 3 hours.
  • controllers[]: id, kind, whether it is the default, and whether it is enabled.
  • ha: the Home Assistant relationship in both directions: env_configured (HA_URL set), reachable (last HA poll succeeded), snapshot_source (standalone or home_assistant), mqtt_discovery (outbound MQTT publishing on), hacs_last_seen_epoch and hacs_streaming (whether the Home Assistant integration has fetched the manifest or is holding a live event stream right now).

If authentication is enabled and you call /api/v1/health without credentials, you get a trimmed body: status, config_present, version, uptime_s, and subsystems only. Sources, controllers, and the ha block are removed so an anonymous probe cannot map your network. Docker healthchecks and uptime monitors keep working either way.

Compose healthcheck

The image ships a built-in HEALTHCHECK that curls http://127.0.0.1:8090/api/v1/info every 30 seconds. If you move LocalSky off port 8090, override it in compose:

services:
  localsky:
    # ...
    healthcheck:
      test: ["CMD", "curl", "--fail", "--silent", "--max-time", "4", "http://127.0.0.1:8091/api/v1/info"]
      interval: 30s
      timeout: 5s
      start_period: 30s
      retries: 3

/api/v1/info is the cheapest liveness probe. Use /api/v1/health?strict=1 if you want your monitor to alert on degraded, not just on dead: with strict=1 the endpoint answers 503 whenever the status is not ok (plain /api/v1/health always answers 200 and says what is wrong in the body). For a bug report, GET /api/v1/diagnostics returns one JSON bundle with health, info, the last 300 log lines, the config with secrets redacted, and the current decision trace; it is scrubbed against your own secret values and safe to paste.

Install and first boot

Container exits immediately with a bind error

The log will end with a line like:

bind 0.0.0.0:8090: is another service holding this port?

Something else on the host already owns the port. Either free it, or move LocalSky:

docker run ... -e LEPTOS_SITE_ADDR=0.0.0.0:8091 -p 8091:8091 ...

This bites most often with network_mode: host, where the container shares the host’s port space directly (no -p remapping is possible). Pick a free port via LEPTOS_SITE_ADDR and remember to override the healthcheck (above).

Wizard cannot save, or history is missing, with permission errors in the logs

The app runs as the non-root user uid 10001, and the container fixes the ownership of /data to that user on every startup, so a normal bind mount or named volume needs no manual chown. If you still see permission errors (the wizard cannot save localsky.toml, or history is disabled with a logged SQLite open failure), the cause is almost always one of:

  • /data is mounted read-only. The container cannot fix ownership of, or write to, a read-only mount. Mount /data read-write.
  • /data is a NAS / NFS share the container can’t chown (Synology, QNAP). Exports with root_squash or “map all users” squash the container’s root, so it is not allowed to chown the volume to uid 10001. LocalSky handles this automatically: when it detects /data isn’t writable as 10001, it runs as the volume’s actual owner instead and logs running as its owner <uid>:<gid>. If you’d rather pin it, set PUID/PGID to the uid:gid that owns the share (find it in Synology File Station, or run id on the NAS):
    environment:
      - PUID=1026     # the share's owning uid
      - PGID=100      # the share's owning gid
    
  • You overrode the entrypoint (a custom entrypoint:, or user: set to a uid that can’t write the volume). Prefer PUID/PGID over user: so the entrypoint can still fix ownership and pick a working uid.

As a last resort you can pre-own the host directory yourself: sudo chown -R 10001:10001 /opt/localsky/data (use whatever uid you set in PUID).

Low-power hardware

  • Raspberry Pi 4/5: the image ships arm64, but the OS must be 64-bit. uname -m should report aarch64. 32-bit Pi OS is not supported.
  • LocalSky idles around 30 MB resident, so nothing special is needed beyond that. The SQLite database sees light write traffic (run rows, sensor samples), which is fine on an SD card, though an SSD never hurts.

Weather sources

Tempest station shows no data

The Tempest hub broadcasts UDP packets on port 50222 to your LAN’s broadcast address. Docker’s default bridge networking does not deliver broadcast traffic into a container, so a bridge-networked LocalSky never hears the hub even though everything looks configured. Run with host networking:

services:
  localsky:
    network_mode: host

To confirm packets are actually arriving on the host:

sudo tcpdump -i any -c 3 udp port 50222

If tcpdump sees packets and LocalSky still shows nothing, check the source is enabled under Settings, then Sources, and watch docker logs for parse errors.

Ecowitt discovery finds nothing

Discovery works by sending a broadcast datagram on UDP 46000 and listening about 3 seconds for gateway replies. Two requirements:

  1. Host networking (same broadcast limitation as Tempest above).
  2. The gateway must be on the same subnet as the LocalSky host.

If discovery still comes back empty, skip it and add the gateway manually: create an ecowitt_gw_poll source under Settings, then Sources, and enter the gateway’s IP address. Alternatively, point the gateway’s own custom upload (WSView Plus or the console UI) at LocalSky’s receiver: protocol Ecowitt, path /ingest/ecowitt, your LocalSky host and port.

A source went stale: what happens to watering?

Nothing dramatic, by design. When an enabled source crosses the offline threshold:

  • /api/v1/health flips to degraded.
  • A dismissable banner appears at the top of the UI naming the offline source(s), with a link to the Sensors hub. Dismissing it snoozes that exact set of sources for the session; a new failure re-raises it.
  • The engine keeps deciding from the freshest data it has. Field merging picks the highest-priority source with a recent observation (ties broken by recency), and rain totals take the max across sources so one dead gauge cannot mask real rain. Sensor-dependent extras (soil-saturation skip, for example) sit out while their probe is silent; the weather and ET math stays on.

Controllers

Controller was offline when watering should have started

Runs do not queue. When the morning scheduler dispatches a zone and the controller call fails, LocalSky logs a warning (controller dispatch failed with source=smart_morning, then smart morning: segment not dispatched naming the segment), abandons the rest of that zone’s segments, moves on to the next zone, and records the failure for that zone in History with the controller’s own error text. As long as LocalSky itself keeps running there is no retry later in the day; the next attempt is tomorrow’s window. Check docker logs around your dispatch time and fix the controller’s reachability (power, IP change, password).

If LocalSky restarts while the watering window is still open (a redeploy, an out-of-memory kill, a host reboot) it re-checks the morning at boot, and the outcome depends on how far the sequence got:

  • Every zone’s dispatch failed. Every row for the morning is a dispatch failure, so the morning does not count as handled. If the grace period below has not expired and the controller is reachable again, the catch-up waters. A morning that mixes a failure with a zone the engine itself decided to skip counts as handled: the skip row is a decision about the yard, and the refused zones wait for tomorrow.
  • Some zones watered before the failure. Each zone is judged on its own evidence: a zone that received its planned water since the window opened is left alone, and a zone that received less is dispatched for the remainder. The morning counts as handled only once every planned zone has had its water (or a skip row says the engine decided otherwise), so a restart after a controller died partway through finishes the dry zones rather than re-running the whole sequence on top of water already on the ground.

A zone the controller currently reports as running is skipped by the morning dispatcher, on the scheduled path and the catch-up path alike, so a catch-up cannot re-open a valve that is already open. A run you start by hand, from the zone page or from a manual schedule, is not gated this way: it commands the zone on whatever the controller currently reports.

Different case: if LocalSky itself was down through the morning window, it catches up at boot. Within a 2 hour grace period after the planned finish time it dispatches a late run (if the verdict is still “run”); past that, it records a skipped row with the reason “Missed dispatch window (LocalSky offline)” so the history stays complete.

A smart-morning run is missing from History after a restart

A smart-morning run is written to History when the zone stops, so a restart between a zone’s start and its stop loses that record. The water reached the ground; History will not show it, and the weekly water balance will not count it, so the zone may come up for watering sooner than it needed to.

Runs you start by hand and runs from a manual schedule behave differently: LocalSky writes those to History at dispatch, at their full planned length, so a restart never loses them. It does mean the opposite error. LocalSky closes every valve at boot, so a hand-started or scheduled run cut short by a restart is still credited in full, and the zone may come up for watering later than it needed to. If you need to restart LocalSky, doing it outside your watering window avoids both.

Zone is running but the dashboard disagrees (or vice versa)

The dashboard’s view of controller state comes from a poll loop that refreshes roughly every 10 seconds (with backoff during outages), so a few seconds of lag is normal. If the disagreement persists:

  • Check the controllers block in /api/v1/health: is the controller enabled, and is one marked default?
  • Runs started from the controller’s own app or front panel show up via the status poll, but they were not planned by LocalSky and may not appear in its run history the way engine-dispatched runs do.

Verify wiring with the DryRun controller

Before trusting a new setup with real valves, add a controller of kind dry_run. Every dispatch is logged (dry_run: would have run zone ...) instead of actuated, and with simulate_runs enabled it writes completed rows to the runs table so the dashboard and history render exactly as they would for real hardware. The wizard’s zone scan against a DryRun controller returns sample zones (Front Lawn, Back Lawn, Garden Beds) so you can rehearse the full add, test, scan, import flow with zero hardware. See Controllers.

Watering decisions

My zones started watering right after I upgraded to 0.7.22

Expected, on a Home Assistant deployment. Until 0.7.22, run lengths there were sized by a Smart Irrigation entity and by nothing else, so an install without that HACS integration planned zero minutes on every zone and dispatched nothing. 0.7.22 sizes runs from LocalSky’s own weekly water budget on every deployment, so those zones water for the first time on the first morning after the upgrade.

If you never set a zone’s weekly target, LocalSky infers one from the zone’s species: 1.00 inches a week over two sessions for warm-season turf, scaled by each species’ own peak crop coefficient, each held to the zone’s maximum run time. Open Settings, then Zones, open the zone and set Weekly target and Sessions per week; blank shows the inferred default in the box. Check Max run time there too. The zone list marks every zone still running on an inferred target. To hold everything while you decide, set Rain delay on the irrigation page.

Why did my zone skip today?

Every skip is recorded per zone with its reason. Open the zone’s skip breakdown in the UI, or look at the run history. The full explanation of each threshold lives in Skip thresholds explained, and the reporting views in History and reporting.

A zone never waters and the card just says a number

Open the zone card or the zone detail. A zone the weekly budget zeroed reads ON HOLD with the allocator’s own sentence under it, naming which gate fired: the week is already covered by rain and prior watering, rain is forecast inside the next 24 hours, or the session spacing has not elapsed since the last run. That line is the answer; tune against it.

A zone the soil model governs holds with its own vocabulary: the soil bucket holds (depletion has not crossed the trigger), forecast rain refills the deficit, the morning window fits N of M zones that need water, or delivery is held to the weekly ceiling. The zone detail’s Soil model block shows the deficit, the trigger, and when the zone waters next.

For weekly-governed zones, two settings decide the size: Weekly target (weekly_budget_in, the gross weekly target in inches including rain) and Sessions per week (sessions_per_week), both in the zone editor under Settings, then Zones. The rain-defer threshold is engine.session_rain_defer_in, default 0.10 inches over the next 24 forecast hours weighted by probability; soil-governed zones defer by deficit instead.

Rain counts per day, and each day is capped at what the zone’s root zone can hold, so a single storm no longer covers a whole week on sandy soil: the covered line then names both figures, what fell and what counted. The cap derives from the zone’s soil texture and root depth; Rain the soil can bank per day in the zone editor overrides it.

I added a manual schedule and smart watering stopped for good

A manual schedule’s mode defaults to Override, which stops smart watering for that zone on every day the schedule covers. The zone card and the schedule’s own card in Settings both say so now, naming the days. Delete the schedule to hand the zone back to the engine. Switching it to Floor keeps the scheduled run as a minimum, but a Floor run is watering like any other: it counts against the weekly target and it resets the session-spacing clock, so the engine can only add on top once floor(7 / sessions_per_week) days have passed since the scheduled run. On a zone whose schedule fires as often as its own session cadence, such as a weekly schedule on a 1-session-a-week bed, that leaves no eligible day and the zone reads ON HOLD naming the spacing every day.

A schedule’s water still counts against the zone’s weekly budget on the days the schedule does not cover, because it is water the zone received, and applied water always counts in full (the per-day cap above is a rain rule: irrigation is sized to what the soil takes, a storm is not). A schedule that already delivers the weekly target therefore leaves the engine nothing to add. Lower the zone’s Weekly target, or delete the schedule, if you want the engine sizing the week instead.

The soil Deficit reads a dash

The deficit is computed for every zone with a species and a soil texture, from the soil model’s replay of measured ET, rain, and completed runs (negative = needs water), so a dash appears only where no bucket can be derived: a zone with no species or soil texture configured. Set both under Settings, then Zones, and the deficit fills on the next refresh. Before 0.8.0 the field’s only producer was a Home Assistant Smart Irrigation entity and every install showed a dash rather than a 0.00 nothing measured.

Auth and reverse proxy

Locked out of the owner account

Short version (full procedure in Authentication): stop the container, delete the identity rows from the SQLite database, restart, and re-run account creation:

sqlite3 /opt/localsky/data/irrigation.db \
  "DELETE FROM auth_sessions; DELETE FROM api_tokens; DELETE FROM users;"

Physical access to the data volume is the trust anchor, same as Home Assistant.

Page loads but is frozen: nothing clicks, behind a proxy auth gate

Classic symptom of an external auth gate (oauth2-proxy, Authelia, Caddy forward_auth) swallowing the app’s compiled assets. Browsers fetch /pkg/* (the WASM bundle) and /sw.js (the service worker) without credentials, the gate answers with a 302 to its login page instead of the file, and hydration dies silently: you see server-rendered HTML, but no JavaScript behavior. Exempt /pkg/* and /sw.js from the gate. Examples in Reverse proxy and HTTPS. LocalSky’s own built-in auth already exempts these paths.

Home Assistant integration logs 401s

The API token it was given has been revoked or replaced. The integration starts its reauthentication flow automatically on the next 401: Home Assistant raises a repair/reauth prompt. Create a fresh token in LocalSky (Settings, then Account, then Create token) and paste it into the prompt. Tokens are shown in plaintext exactly once.

Home Assistant

No LocalSky entities in HA

  • The integration is installed from HACS (search for LocalSky in the store); if you only installed HACS itself, the LocalSky integration is not there yet. See Home Assistant integration.
  • The config flow needs a reachable LocalSky URL and, on auth-enabled instances, an API token (lsk_...).
  • Zeroconf discovery (the config flow finding LocalSky by itself) relies on LocalSky’s mDNS announce (_localsky._tcp), which only reaches the LAN when LocalSky runs with host networking. With bridge networking, just enter the URL manually.

Duplicate entities

You have both publishing paths on at once: MQTT discovery (LocalSky publishing to your broker) and the HACS integration (HA polling LocalSky) each create their own set of localsky entities. Pick one. To keep the integration, turn off MQTT publishing under Settings, then Notifications, and delete the leftover MQTT device in HA (Settings, Devices & Services, MQTT).

Entities unavailable, but LocalSky is still watering

Expected, and it is the point of standalone operation: the engine and scheduler run inside LocalSky and do not depend on HA being up. Unavailable entities only mean HA cannot currently see LocalSky’s state. The one exception is controllers of kind ha_service_call, which dispatch through HA and do need it reachable.

FAQ

Does my data leave my network?

Only when you ask it to. By default LocalSky makes no calls home, and the app itself runs no analytics. The outbound traffic that can exist:

  • Forecast sources you configure (Open-Meteo, NWS, OpenWeather, Pirate Weather, MET Norway): polled requests carrying your coordinates and any API key you supplied. NWS and MET Norway also require an identifying User-Agent by their terms of service; if you leave that field empty, LocalSky sends localsky/<version> (instance-<first 8 characters of the install id>; +https://localsky.io), and you can set your own contact string instead.
  • Cloud-bridged hardware you add (Tempest WebSocket, Netatmo, Ambient Weather, Tuya, YoLink sources; Rachio, Hydrawise, B-hyve controllers): those vendors’ clouds, with the credentials you entered.
  • The optional update check: a plain daily GET to the project’s version manifest at localsky.io/latest.json, off by default, opt-in via [updates].check_enabled. The request carries the running version in its User-Agent (so the maintainer can see which versions are in use); no per-install identifier or config data rides along.
  • Web Push notifications, if you enable them: encrypted payloads to your browser’s push service.

Pure-LAN setups (local station, OpenSprinkler, no forecast sources) generate zero outbound traffic.

Do I need Home Assistant?

No. LocalSky is a complete standalone product: its own engine, scheduler, controller drivers, dashboard, and notifications. HA is one optional integration path among several. See Standalone mode.

What hardware works with it?

Weather: Tempest, Ecowitt gateways and soil probes, Davis WeatherLink Live, Synoptic Data (pulls your nearest real station over a free token), NOAA MRMS radar rain (US radar-derived rainfall), plus cloud and generic MQTT/webhook sources; see Weather + soil sensors. When you configure more than one source, per-field priority chains let each reading (rain, wind, temperature, and so on) fall through an ordered primary-then-backup list, so the merged picture keeps updating even if one source goes quiet. Irrigation: OpenSprinkler is the canonical direct-LAN controller, with HA service-call, MQTT, cloud (Rachio, Hydrawise, B-hyve, Rain Bird), and others; see Controllers.

What does “beta” mean here?

LocalSky is in its 0.x release line (check Settings > About, or GET /api/v1/info, for the exact version you are running). The engine math (FAO-56) is stable, but the API wire format is not semver-locked until 1.0, and features and config fields can still change between releases. Config files carry a schema_version and migrate forward automatically at boot, so upgrades are safe; still, keep backups, and rehearse new controller setups with the dry_run controller before letting the engine drive real valves.

Where is my data?

Everything lives in the /data volume you mounted: localsky.toml (configuration), irrigation.db (SQLite: run history, sensor samples, accounts, tokens), and a small instance-identity file. Nothing is stored in any cloud.

Can I move LocalSky to a different host?

Yes. Either copy the /data directory to the new host, or use the built-in bundle: GET /api/v1/backup downloads a tar.gz of config plus a consistent database copy, and POST /api/v1/backup/restore loads it on the new instance. See Backup and restore.

Can I run two instances?

You can (separate data volumes, different ports), and a second instance in demo mode is a handy sandbox. What you should not do is point two live engines at the same controller: each one runs its own scheduler, so the same zones would be dispatched twice.

Why did it skip watering today?

There is always a recorded reason per zone: rain already received, rain expected, wind, temperature, soil moisture from a probe, restriction calendars, and so on. The UI shows the exact threshold that tripped. A zone that is not skipping but still plans zero minutes reads ON HOLD on its card, with the weekly water balance’s own reason beside it. See Skip thresholds explained and History and reporting.

Can I enter thresholds in metric?

Display units are configurable in Settings > Units. You choose the units for temperature, rainfall, wind, pressure, distance, and zone area independently, and the choice sets a household default that any individual device can override with its own preference. Every reading and every plain-language reason renders in the units you picked. The one exception is input: the skip-threshold input fields (already-wet, max wind, min temperature, rain skip, and friends) currently accept imperial values only; metric input is on the roadmap. The docs list metric equivalents next to every default so you can translate while you tune.

Does it need internet access?

Not for the core loop. A LAN weather station plus a LAN controller (Tempest or Ecowitt plus OpenSprinkler, say) keeps measuring, deciding, and watering with the WAN unplugged. Forecast-driven features (forecast merge, rain-hold lookahead, the 7-day verdict strip) need egress to whichever forecast providers you configured.

Is there telemetry?

No tracking lives in the app: no usage reporting, no crash reporting, no analytics SDK, nothing sent to the maintainer beyond the optional update check above, off by default. When you enable it, the daily request to localsky.io carries the running version in its User-Agent (no per-install identifier), and (as with any web request) the server can see your IP; the maintainer reads those access logs only as aggregate version counts. One separate case: NWS and MET Norway require an identifying User-Agent by their terms, so requests to those two agencies carry a short per-install tag (the first 8 characters of the install id) unless you set your own contact string in the source’s settings. That identity goes to the weather agency you chose, never to the maintainer. Nothing else is collected, and nothing is stored in the app.

Glossary

  • ET0: reference evapotranspiration; how much water (mm/day) a standardized grass surface would lose to evaporation plus transpiration under today’s weather.
  • ETc: crop evapotranspiration; ET0 adjusted to your actual lawn (ETc = ET0 x Kc), how much water the lawn loses each day.
  • Kc: crop coefficient; a per-species, season-aware multiplier that converts ET0 into ETc.
  • MAD: management allowed depletion; the fraction of TAW the engine lets the soil dry out before watering is triggered.
  • TAW: total available water; how much water (mm) the root zone can hold between field capacity (full) and wilting point (empty).
  • Soil bucket: a per-zone depletion model where rain and irrigation fill and ETc drains. The soil scheduling model waters a zone when the bucket’s deficit crosses its trigger and refills it; the deficit shows on every zone’s tiles whichever model governs.
  • Weekly water balance: the scheduling model an install or a zone follows by pinning weekly. A gross weekly target per zone, settled against observed rain (credited per day, each day capped at what the root zone can hold), water already applied, and a probability-weighted forecast credit; the remainder is split across the sessions still expected this week. The shipped default is the soil bucket; scheduling_model is where you pin either one.
  • Verdict: the engine’s daily decision for the yard: run or skip, with the reason attached.
  • HAL: hardware abstraction layer; the Rust trait every controller adapter implements, so the engine speaks one language to OpenSprinkler, Rachio, HA service calls, and the rest.
  • FDR: frequency domain reflectometry; the measuring principle behind common soil-moisture probes, whose raw readings LocalSky calibrates into a percentage.
  • zeroconf: zero-configuration networking (mDNS); LocalSky announces itself as _localsky._tcp on the LAN so clients like the Home Assistant integration can find it without you typing an IP.

Configuration reference

LocalSky’s configuration is a single TOML file at /data/localsky.toml. The first-run wizard writes it; the settings UI edits it; every PUT /api/v1/config validates and then writes it atomically (write to a temp file, rename). Schema lives in src/config/schema.rs.

This document is the field-by-field reference. The wizard (docs/getting-started.md) is the conversational walkthrough; this is the lookup table.

Top-level structure

schema_version = 2

[deployment]
[features]
[[sources]]
forecast_provider = "..."     # optional: pin the forecast provider (a source id)
[field_source_overrides]      # optional: per-reading single pin (reading -> source id)
[field_source_chains]         # optional: per-reading ordered backup chain
[[controllers]]
[zones.<slug>]
[llm]
[notifications]
[engine]
[[manual_schedules]]
[scripting]
[conditions]
[auth]
[network]
[updates]
[persistence]
[ui]

Every section except deployment is optional (zero-source / zero-controller configs are valid for first boots before the wizard has been completed). schema_version is required; a config whose schema_version is higher than the binary supports is refused at load (see Upgrading LocalSky).

[deployment]

[deployment]
location = { lat = 52.52, lon = 13.40, elevation_m = 34 }   # your coordinates, decimal degrees
units = "metric"
timezone = "Europe/Berlin"                                  # your IANA timezone
display_name = "My Yard"

Or, for a US install:

[deployment]
location = { lat = 28.5, lon = -81.4, elevation_m = 30 }
units = "imperial"
timezone = "America/New_York"
display_name = "My Yard"
  • location.lat / location.lon: required, decimal degrees
  • location.elevation_m: optional, used by FAO-56 net-radiation
  • units: "metric" or "imperial". The setup wizard pre-selects this from your location; existing configs keep their value. Configs written without the field fall back to "imperial" for backward compatibility. Per-field overrides live in browser localStorage, not here
  • timezone: optional IANA name. Null derives from lat/lon at boot
  • display_name: surfaces in the MQTT discovery node_id (slugified) and the dashboard title
  • ha_sprinkler_prefix: HA-mode controller entity prefix, default "opensprinkler" for existing installs. Settings > Home Assistant > Advanced shows an editable prefix and previews the exact enabled, water-level, and per-zone running entities. A changed prefix requires restart so readback and action bindings use the same names. Missing or unavailable running entities remain unknown; they never certify an idle valve.

[features]

[features]
demo_mode             = false
enable_mqtt_publish   = true
enable_advisor        = true
enable_push           = true
nerd_mode_default     = false
telemetry             = false

All defaults shown. demo_mode is a readout: LOCALSKY_DEMO=1 seeds a demo config (four zones, a dry-run controller, synthetic weather) and sets it. Setting it by hand changes nothing; the demo_replay source kind is the synthetic weather feed a demo uses and can be added to any config.

[[sources]]

A list. Each entry has an id, priority, enabled, and a kind discriminator with per-kind config block.

[[sources]]
id = "tempest_lan"
priority = 100
enabled = true
kind = "tempest_udp"
[sources.config]
bind_addr = "0.0.0.0:50222"
hub_serial = null  # filter to a specific Tempest hub; null = accept any

Supported kind values: tempest_udp, tempest_ws, open_meteo, ecowitt_local, ecowitt_gw_poll, davis_wll, nws, openweather, pirate_weather, met_norway, synoptic, noaa_mrms, ambient_weather, netatmo, yolink, lacrosse, tuya_cloud, ha_passthrough, mqtt, http_webhook, rest_poll, prometheus, influxdb, weatherkit, demo_replay. See src/config/schema.rs SourceKind enum for per-kind config fields.

New in 0.7.0: synoptic (Synoptic Data / MesoWest, a dense real-station observation network keyed by a free API token, current wind/pressure/temp/humidity like NWS but from a much denser mesonet) and noaa_mrms (NOAA Multi-Radar Multi-Sensor, keyless US-only gauge-corrected radar rain that sees the rain on your block, refreshed about every 2 minutes). Both emit only current scalars into the merge, not forecast snapshots.

Two kinds deserve a callout because they accept data from anything:

  • mqtt subscribes to broker topics (Tasmota, ESPHome, Zigbee2MQTT, any raw publisher). Config: broker_host, broker_port (default 1883), optional username/password, and a subscriptions list mapping each topic to a weather field with optional scale/offset.
  • http_webhook accepts JSON POSTs at a path you choose under /ingest/ from anything that can speak HTTP (Arduino, a Pi script, a commercial gateway). Config: path, optional shared-secret token (sent as the X-LocalSky-Token header or ?token= query parameter), and a fields mapping list.

priority matters when multiple sources report the same field. Convention: 100 = LAN station; 50 = forecast model; 10 = fallback. Cloud forecast sources added through the UI are re-ranked automatically to the researched region defaults (US: NWS 70 > Pirate 60 > OpenWeather/WeatherKit 55 > Open-Meteo 50; Europe/Nordics: Met.no 70; NWS is auto-disabled outside the US where it has no coverage).

Default forecast failover chain

Every located install automatically carries its region’s keyless forecast authority alongside Open-Meteo: NWS + NOAA MRMS in the US, MET Norway in Europe and the Nordics. They are seeded once, at their region rank (above the Open-Meteo 50 backstop), including on existing installs at upgrade, so a single provider outage cannot blank the forecast, the 7-day verdicts, or the rain-skip inputs. Deleting a seeded source is permanent; LocalSky records the id in seeded_source_ids and never re-adds it. When a lower-ranked provider is serving because the primary has gone quiet, the forecast header shows an amber “via [provider] · backup” link into the source status page. Open-Meteo itself also retries against two verified Open-Meteo mirror hosts before giving up, and data served that way is labeled “Open-Meteo (mirror)”.

Open-Meteo past days

The open_meteo source’s past_days (default 3, clamped 1..=7) sets how many past days of daily model data ride along with the forecast. Those archived days are the model-archive rung of the observed-rain ladder: on installs without a gauge or radar day totals they are what the weekly water balance and the days-since-rain backstop read. The value is honored live since 0.7.17 (earlier builds always fetched 3 regardless of the setting); a config save applies on the next forecast refresh, no restart. A persisted past_days = 1 is rewritten to 3 on load: 1 was the old never-honored template default, not an operator choice, and honoring it as written would shrink the archive on upgrade.

Related: the observed-rain ledger (forecast_observations) tags each day’s total with its source (gauge | radar | none). A day whose rain owner is a model fill, stale, or absent records a none placeholder that never trains the bias model and never counts as a measured-dry day (a model’s “rain today” is a whole-day forecast, not an observation); rows from before 0.7.17 read legacy and are treated as gauge-quality only on installs with a station source.

Self-hosting Open-Meteo

Open-Meteo’s engine is open source, and LocalSky can point at your own instance: set endpoint on the open_meteo source to its base URL. Your instance becomes the FIRST rung of the endpoint ladder; the hosted api.open-meteo.com and its mirrors stay behind it as automatic fallback, so a down self-hosted box degrades gracefully instead of blanking the forecast. Data served this way is labeled “Open-Meteo (self-hosted)”.

[[sources]]
id = "open_meteo"
[sources.config]
endpoint = "http://192.0.2.10:8080"

What self-hosting actually does: the open-meteo container serves the same /v1/forecast API from model data it syncs to local disk. The data still comes from upstream (it downloads processed model runs from Open-Meteo’s public AWS open-data bucket on a schedule), so you are not eliminating the data dependency; you are moving the failure domain. The API tier becomes yours (LAN latency, no rate limits, immune to api.open-meteo.com outages like 2026-07), while the sync runs in the background and a missed sync just means a gradually aging model run instead of an immediate outage.

Honest sizing: one regional high-resolution model with a limited history window is a few GB of disk and modest steady bandwidth; adding global models multiplies that quickly (tens of GB and up). A reasonable single-home setup syncs just the model that covers you (e.g. ncep_hrrr_conus plus ncep_gfs013 fallback in the US, dwd_icon_d2 + dwd_icon in Europe). See github.com/open-meteo/open-meteo for the container and sync configuration. For most installs the hosted service plus LocalSky’s built-in mirror ladder and regional failover chain is plenty; self-host when you want LAN-only forecasts, you hit rate limits, or you simply like running your own weather API.

Per-field source selection

Three optional top-level keys let you steer which source drives each reading, on top of the per-source priority. All three are additive: leave them out and the plain priority merge applies unchanged.

field_source_chains maps a reading name (temperature, humidity, wind_mph, rain_today_in, pressure_in_hg, and so on) to an ordered list of source ids. The first source in the chain that is reporting fresh data owns the reading; if it goes quiet the next takes over. A reading with no chain falls back to the global per-source priority arbitration. Example:

[field_source_chains]
rain_today_in = ["ecowitt", "nws", "open_meteo"]
wind_mph = ["tempest", "open_meteo"]

field_source_overrides is the older single-pin form of the same idea: a reading name mapped to one source id. A pin is just a one-element chain, and the safety fallback is the same (the pin only wins while its source has a fresh value; otherwise the priority merge takes over). New configs should prefer field_source_chains; a bare pin still works.

[field_source_overrides]
pressure_in_hg = "davis"

forecast_provider pins which forecast-capable source drives the whole forecast pipeline (the daily/hourly arrays, ET0, and rain-tomorrow). The value is a source id for a forecast kind (open_meteo, nws, met_norway, openweather, pirate_weather, weatherkit). null (the default) keeps the priority arbitration, with Open-Meteo as the low-priority failover; naming a provider pins it to win regardless of ranking. An absent or disabled id is silently ignored, so a pin never blanks the forecast.

forecast_provider = "nws_forecast"

The Settings > Devices data-sources editor is the visual equivalent: drag rows or use arrow keys to reorder each reading’s chain (toggling between Automatic smart defaults and Custom), and pick the forecast provider. Soil moisture is out of scope here; it is bound per zone via soil_sensor_id.

[[controllers]]

[[controllers]]
id = "os_main"
default = true
enabled = true
kind = "opensprinkler_direct"
[controllers.config]
host = "192.0.2.10"
port = 80
password_md5 = "..."
poll_interval_s = 10

Exactly one controller should have default = true. The validator rejects PUTs that leave the system with zero defaults when any controller exists.

Supported kind values: opensprinkler_direct, http_generic, mqtt_command, ha_service_call, rachio, hydrawise, bhyve, rainbird, dry_run. (esphome_native is scaffolded but not yet built, so it is not offered in the UI; use mqtt_command or http_generic for ESPHome hardware.)

Editable, migrating ids

Source and controller id fields are editable. Renaming one migrates every reference automatically: the field_source_chains picks, the forecast_provider pin, each zone’s soil_sensor_id, and each zone’s controller_id. A rename never leaves a dangling reference. Rename through the Settings UI or by editing the config and applying it.

[zones.<slug>]

Keyed by zone slug. Each zone:

[zones.back_yard]
display_name = "Back Yard"
area_sqft = 1800
species = "st_augustine"
soil_texture = "sandy_loam"
slope_pct = 2.0
sun_exposure = "full"           # full | partial | shade
sprinkler_type = "rotor"         # rotor | spray | mp_rotator | drip | bubbler
precip_rate_mm_hr = 14.2         # measured via catch-cup; null = catalog default
precip_rate_source = "measured"  # measured | catalog
root_depth_mm = null             # null = species default
mad_pct_override = null          # null = species default
max_run_minutes = null           # null = 60; longest single run (whole minutes, 5..=360)
controller_id = "os_main"
controller_station = "1"         # the controller's own id for this zone
controller_zone_name = null      # the controller's name for it; a label only
soil_sensor_id = null            # optional; no probe just means no soil gate
target_min_pct_soil = 30.0
saturation_pct_soil = 70.0
weekly_budget_in = null          # null = 1.00 turf, 0.50 shrub/garden/bed
sessions_per_week = null         # 1..=7; null = 2 turf, 1 shrub/garden/bed
rain_credit_cap_in = null        # 0.05..=5.0 in/day; null = derived from soil + roots
scheduling_model = null          # null = the engine default; weekly | soil
photo_url = null

weekly_budget_in and sessions_per_week are the two settings that size a run; the zone editor (Settings, then Zones) carries them as Weekly target and Sessions per week. The weekly balance settles weekly_budget_in (a gross weekly depth, inches including rain) against observed rain, water already applied, and a probability-weighted forecast credit, then splits the remainder across the sessions still expected this week. Neither moves with the season: nothing recomputes them from ET0 or the crop coefficient. When unset, both come from a default set by the zone’s species: its peak crop coefficient against reference turf, so warm-season turf starts at 1.00 in over 2 sessions, a vegetable bed at 1.15 in, and established shrubs at 0.55 in over 1. See Weekly water budget.

rain_credit_cap_in caps how much rain one DAY may credit against the weekly target (inches). Rain beyond it in a single day drains past the roots and does not count. Unset, it derives from the zone’s soil texture and root depth; the zone editor carries it as Rain the soil can bank per day with the derived value in the placeholder. A save outside 0.05..=5.0 is refused; a value already in a config file is clamped into range at load. See Weekly water budget.

scheduling_model pins this zone to one scheduling model regardless of the engine default; null follows engine.scheduling_model. The zone editor carries it as Scheduling model after the rain-cap field. Under soil, the weekly fields change roles: a weekly_budget_in you set by hand acts as a rolling-7-day delivery ceiling on the soil model’s refills instead of sizing sessions (an inferred target never caps), sessions_per_week stops steering because cadence is emergent from soil texture and roots, and rain_credit_cap_in keeps its per-day meaning inside the soil replay. All three keep their full weekly meaning for zones the weekly model governs. See the soil model.

sessions_per_week accepts 1 through 7. Sessions space at floor(7 / sessions_per_week) days, so above 7 that spacing works out to less than a day and stops holding a zone that has already watered today. A save outside the range is refused; a value already in a config file is read as 7 rather than blocking the file from loading.

species enum: st_augustine, bermuda, zoysia, bahia, centipede, kentucky_bluegrass, tall_fescue, perennial_ryegrass, ornamental_shrubs, vegetable_garden, drip_xeriscape, other. See grass-species.md.

soil_texture enum: sand, loamy_sand, sandy_loam, loam, silt_loam, clay_loam, clay. See soil-textures.md.

max_run_minutes caps a single dispatch for the zone (60 minutes when unset). It applies live on save. Raising it past 60 is confirmed in the UI at save time and sends a notification to subscribed devices; an active watering restriction’s per-zone cap still wins via min().

The tuning report’s Apply action writes exactly these fields (soil_texture, precip_rate_mm_hr plus precip_rate_source, root_depth_mm, weekly_budget_in, sessions_per_week, max_run_minutes) through the same validated save path as the settings editor; null restores the documented default.

[llm]

[llm]
provider = "auto"            # auto | ollama | llamacpp | openai_compat
timeout_s = 20
explanation_ttl_s = 300
anomaly_ttl_s = 3600

[llm.config]
# fields depend on provider

auto probes localhost in order: Ollama (11434), llama.cpp (8080), LM Studio (1234). First success wins. Override the probe list via [llm.config] probe_order = ["http://..."].

ollama requires { base_url, model }. llamacpp requires { base_url }; model optional. openai_compat requires { base_url, model }; api_key optional.

Omit the entire [llm] block to disable the advisor.

[notifications]

[notifications]

[notifications.web_push]
vapid_public        = "..."
vapid_private_path  = "/keys/vapid-private.pem"
vapid_subject       = "mailto:[email protected]"

[notifications.mqtt]
host             = "broker.local"
port             = 1883
username         = null
password         = null
discovery_prefix = "homeassistant"
publish_enabled  = true
subscribe_enabled = false

[notifications.ntfy]
base_url   = "https://ntfy.sh"
topic      = "your-private-topic"
auth_token = null

[notifications.slack]
webhook_url = "https://hooks.slack.com/services/..."

Each section is optional. Omit to disable that channel.

[engine]

[engine]
scheduling_model         = "soil"   # weekly | soil; the wizard writes soil for new installs
capture_efficiency       = 0.70     # read by the soil model (see below)
session_rain_defer_in    = 0.10     # weekly-model zones only; soil zones defer by deficit
soak_minutes             = 5        # floor under the derived per-zone soak, not the soak itself
interleave_cycles        = true     # water other zones during soak pauses; turn off for well/low-recovery supplies
et0_method               = "auto"   # not read at all (see below)

[engine.skip_rules]
already_wet_in              = 0.05   # 1.3 mm
rain_now_in_hr              = 0.01   # 0.25 mm/hr
rain_next_4h_skip_in        = 0.10   # 2.5 mm
rain_3day_factor            = 1.5
heat_advisory_temp_f        = 95.0   # 35 C
heat_advisory_humidity_pct  = 60.0
heat_advisory_dry_days      = 2
wind_forecast_slack_mph     = 5.0    # 8 km/h
max_wind_mph                = 10.0   # 16 km/h
min_temp_f                  = 38.0   # 3.3 C
rain_skip_in                = 0.25   # 6.4 mm
frost_skip_soil_f           = 35.0   # 1.7 C

All values match v0.1 hardcoded constants, with two exceptions. See skip-rules.md for what each skip threshold does.

scheduling_model picks which model sizes and schedules smart-morning runs for every zone without a per-zone pin: the weekly water balance (weekly) or the soil model (soil, the default). The Engine settings page carries it, it hot-reloads like the rest of the watering policy, and the setup wizard writes soil for new installs at apply time; an upgraded config keeps whatever it holds. A config that never chose carries no key at all and follows the shipped default (soil today), and saves of unrelated settings keep it that way: only choosing a model on the Engine page (or writing the key yourself) makes the choice explicit.

capture_efficiency is read by the soil model: the replay credits rain and applied water through it and each refill divides by it, so on soil-governed zones editing it changes run length, and the zone math panel there shows the configured value. Weekly-governed sizing does not read it (the weekly target is gross), and the soil projection plus the math panel on weekly zones keep the fixed 0.70. Its other reader is the tuning report’s measured-sprinkler-rate check, which divides a probe’s rise by it. et0_method is accepted and validated but not read: the ET0 path always runs the automatic method (Penman-Monteith when the inputs are there, otherwise ASCE-simplified, otherwise Hargreaves-Samani).

interleave_cycles waters other zones during a zone’s cycle-and-soak pauses instead of idling through them, shortening the morning sequence. Default on; turn it off on installs fed by a well or low-recovery pump, where the idle soak gaps double as supply recovery time (the setup wizard’s water-supply question sets this for you). One valve still runs at a time and soaks are minimums that may stretch, never shrink; details in irrigation-engine.md. interleave_cycles and soak_minutes hot-reload with the rest of the watering policy: a change applies on the next scheduler tick (the next morning’s plan), no restart needed. Both, plus the seasonal water-budget dial, are editable on the Engine settings page.

Watering restrictions

Rules from your water authority, municipality, or homeowners’ association live under [engine] as a list. Empty list (the default) means no restrictions are enforced. When multiple restrictions are active, the engine ANDs them all; the strictest wins.

Example: a Florida water-district rule, keyed to the daylight-saving switch:

[[engine.watering_restrictions]]
id = "sjrwmd_dst"
name = "SJRWMD daylight-saving rule"
enabled = true                      # default: true
effective = { kind = "dst_only" }   # all_year | dst_only | standard_only | date_range
allowed_weekdays_odd  = [3, 6]      # 0 = Sunday .. 6 = Saturday; empty = no parity gate
allowed_weekdays_even = [4, 0]
forbidden_hour_start = 10           # inclusive start of the no-watering window (local hour)
forbidden_hour_end   = 16           # exclusive end
max_minutes_per_zone = 60           # optional per-session cap; min of all active caps wins

Example: an Australian-style summer stage restriction (no watering 10:00-16:00, December 1 to March 31, even-numbered houses Tuesday/Saturday, odd-numbered Wednesday/Sunday):

[[engine.watering_restrictions]]
id = "summer_stage2"
name = "Stage 2 summer restrictions"
effective = { kind = "date_range", start_month = 12, start_day = 1, end_month = 3, end_day = 31 }
allowed_weekdays_even = [2, 6]
allowed_weekdays_odd  = [3, 0]
forbidden_hour_start = 10
forbidden_hour_end   = 16

effective decides when the rule applies: all_year, dst_only, standard_only (the complement), or date_range with start_month/start_day/end_month/end_day (wraparound ranges like Nov 15 to Feb 28 work). dst_only uses US daylight-saving dates (2nd Sunday of March to 1st Sunday of November); outside the US, use date_range for seasonal windows. The odd/even weekday gates only do anything when [deployment] sets address_parity = "odd" or "even"; the default "not_applicable" makes parity gates a no-op.

[[manual_schedules]]

Fixed weekday-and-time schedules that coexist with the smart engine. Each schedule fires one zone:

[[manual_schedules]]
id = "back_yard_mwf"
name = "Back yard, Mon/Wed/Fri early"
zone_slug = "back_yard"        # must match a key under [zones]
enabled = true                 # default: true
weekdays = [1, 3, 5]           # 0 = Sunday .. 6 = Saturday; empty = never fires
start_hour = 5                 # local time, 0..23
start_minute = 30              # 0..59
duration_minutes = 20
mode = "override"              # override (default) | floor
ignore_weather_safety = false   # explicit per-schedule weather waiver; off by default
  • override (default): while an enabled override schedule applies to a zone that day, smart-irrigation dispatch for that zone is suppressed. The zone’s smart plan for that day is zero and the detail panel reads “Scheduled 0 min”; the engine’s other figures still show.
  • floor: the schedule fires AND the smart engine may add more runs that week if the weekly water balance says the zone still needs water. The scheduled run’s water counts against the weekly target like any other run, and it resets the session-spacing clock, so a schedule firing as often as the zone’s own sessions_per_week cadence leaves the engine no day to add on. See Manual schedules.

Manual schedules respect watering restrictions exactly like smart runs do: a blocked dispatch is skipped with the reason logged to run history.

Rain delay, vacation pause, hold-all, and active Skip overrides also stop a manual schedule. ignore_weather_safety = true is a separate deliberate waiver: the UI requires a confirmation naming the physical risks, marks the saved schedule Ignores weather, and records the bypassed gate when it dispatches. The waiver never defeats an operator hold or a watering restriction. The dashboard’s Force control has narrower scope: it bypasses rain and soil recommendations while enabled safety checks remain binding.

[auth]

Authentication policy. Identity itself (accounts, sessions, lsk_ API tokens) lives in the SQLite database, not in this file; this block only sets the policy. Full walkthrough: Authentication.

[auth]
mode = "disabled"          # disabled (default) | required
session_ttl_days = 30      # rolling browser-session lifetime
trusted_networks = []      # CIDRs that skip auth while mode = "required", e.g. ["10.0.0.0/24"]
trusted_proxies = []       # CIDRs of YOUR reverse proxies; makes X-Forwarded-For believable
# proxy_auth_header = "X-Auth-Request-Email"  # identity header an authenticating proxy stamps
# proxy_auth_allow = ["[email protected]"]      # allowed values (case-insensitive); empty = any non-empty value

Configs without an [auth] block behave exactly as before (no login). With mode = "required", static assets, /api/v1/info, and the /ingest/* receivers stay public; everything else needs a session or a Bearer token.

proxy_auth_header names the identity header an authenticating reverse proxy (for example oauth2-proxy’s X-Auth-Request-Email) stamps on requests it has already logged in. It is honored only when the request’s direct peer is inside trusted_proxies, and it vouches the caller as an authenticated operator on the privileged config/backup routes in both auth modes. proxy_auth_allow optionally restricts which header values qualify. The proxy must strip or overwrite the header on client traffic. Full walkthrough: Authentication.

[network]

[network]
mdns_enabled = true   # default: true

Announces _localsky._tcp via mDNS so the Home Assistant integration and LAN clients can discover the instance. Announce-only; needs host networking under Docker to be visible beyond the container.

[updates]

[updates]
check_enabled = false   # default: false

Off by default; nothing phones home. When enabled (restart required), LocalSky polls the project version manifest at localsky.io/latest.json about once a day (the running version travels in the User-Agent, nothing per-install) and serves the comparison at GET /api/v1/updates. Nothing self-updates; docker pull stays the upgrade mechanism. See Upgrading LocalSky.

[persistence]

Local-history retention knobs for the SQLite database. Both default to sensible values; set them only if disk is tight.

[persistence]
retention_days      = 90   # default: 90
runs_retention_days = 0    # default: 0 (keep forever)
  • retention_days: days of raw sensor_history readings to keep. Rows older than this are pruned opportunistically as new readings arrive. 0 disables pruning (keep everything forever).
  • runs_retention_days: days of run / skip / decision history to keep. 0 (the default) keeps everything forever, which is what makes year-over-year trends in History possible. Set a cap only if disk is genuinely tight.

[ui]

Server-side UI presentation defaults. These set the baseline; per-browser choices persist in each device’s localStorage and win over them once a user changes something.

[ui.radar]
providers      = []                          # default: [] (Auto, region-smart set)
default_layers = ["precip", "nexrad", "..."] # overlays on for a browser with no saved preference
  • ui.radar.providers: the radar tile providers offered in the layer menu, by catalog id (see radar_catalog::providers()). Empty (the default) means Auto: the region-smart recommended set for your station location. Non-empty means exactly this menu, in this order; any catalog provider is allowed anywhere, so you can deliberately switch on an out-of-region source to compare.
  • ui.radar.default_layers: which overlays are enabled by default for a browser that has no stored preference yet. Accepts provider ids and feature ids from the radar catalog. Once a user toggles layers, their per-browser choice persists in localStorage and wins over this list.

Env var interpolation

Anywhere a string field appears, you can interpolate environment variables via ${NAME}. Useful for secrets:

[notifications.web_push]
vapid_public  = "${VAPID_PUBLIC}"
vapid_private_path = "${VAPID_PRIVATE_PATH}"

Escape with $${literal} if you need a literal ${...} in the value.

Validation

PUT /api/v1/config validates structurally (serde decode) and semantically:

  • schema_version must equal or be less than what the binary supports
  • Source ids and controller ids must be unique
  • Exactly one controller can have default = true (zero is allowed only when [[controllers]] is empty)
  • Each zone’s controller_id must reference a configured controller
  • lat in [-90, 90], lon in [-180, 180]

Bad PUTs return 422 with the specific failure; on-disk file is untouched.

Migrations

There are two migration chains. Config migrations (schema_version in localsky.toml) are an ordered list applied exactly once on load and recorded in localsky.ledger.toml beside the config; that ledger also holds the server-owned records (seeded forecast authorities, the 0.7.22 helper migration) that no config write can touch, and it travels inside a backup bundle. On boot, the database migration runner replays any database migrations the file has not seen yet. Schema bumps live in src/persistence/migrations/ as numbered SQL files, each applied in its own transaction and recorded in the schema_migrations table. The config file’s own schema_version is currently 2; older configs gain new fields via defaults, and a config newer than the binary is refused at load. Details: Upgrading LocalSky.

LocalSky records a config snapshot on every save. Each successful write (a settings PUT, a raw-TOML save, or the wizard apply) first copies the previous on-disk localsky.toml to <config_dir>/snapshots/<unix_ts>.toml, keeping the newest 20 and pruning older ones. To list and restore them:

# List available snapshots (newest first).
curl http://localhost:8090/api/v1/config/snapshots
# -> {"snapshots":[{"ts":1765400000,"applied_at_epoch":1765400000,"schema_version":2,"note":null}, ...]}

# Roll back to one. The snapshot is validated before the swap, and the
# current config is snapshotted first so the rollback is itself reversible.
curl -X POST -H 'Content-Type: application/json' \
    -d '{"ts": 1765400000}' \
    http://localhost:8090/api/v1/config/rollback

POST /api/v1/config/rollback also accepts the legacy ?to=<ts> query form. A rollback hot-reloads the restored config into the running engine just like a normal save. Snapshots cover config only; keep backup bundles for full config-plus-database history.

Programmatic schema

The JSON Schema is published at runtime: GET /api/v1/config/schema. The settings UI uses it to generate form widgets and to validate input client-side. Schemars-derived, so it tracks the Rust struct definitions exactly.

Backup + restore

Covered in full in Backup, restore, and recovery. The short version: all persistent state is /data/localsky.toml plus /data/irrigation.db, and GET /api/v1/backup hands you both as one consistent .tar.gz (also available as the Download backup button under Settings -> Advanced).

Optional analytics for public instances

LocalSky never sends telemetry. If you run a public instance (a demo, a showcase) and want to measure visits with your own analytics tool, set all of these and the app shell renders one script tag; leave them unset (the default) and nothing is loaded or sent, ever:

LOCALSKY_ANALYTICS_SRC=/stats/u.js            # your tracker script URL
LOCALSKY_ANALYTICS_WEBSITE_ID=<your-site-id>  # data-website-id value
LOCALSKY_ANALYTICS_HOST_URL=                  # optional data-host-url

Location

Latitude, longitude, and elevation anchor everything: sunrise and sunset for scheduling, solar geometry for evapotranspiration, the timezone (inferred offline from coordinates), forecast grid points, and radar centering.

Set it once in the wizard, by address search or by coordinates. Elevation is auto-resolved when omitted. Changing location later (Settings > Hardware > Location) re-infers the timezone and re-anchors the forecast sources on their next poll.

LocalSky is hemisphere-aware end to end: the FAO-56 solar math is signed-latitude correct, species curves flip seasons south of the equator, and polar-edge cases (no sunrise) fall back to fixed scheduling gracefully.

API reference

LocalSky exposes a REST + SSE API mounted at /api/v1/ (canonical) and /api/ (legacy alias). New clients should target /api/v1/*. These historical route names are independent of the response contract version reported by /api/v1/info: the local 0.9.0 candidate uses API 2.1.0 at the existing URLs. A few newer endpoint families (/api/v1/backup, /api/v1/updates, /api/v1/diagnostics) exist only under /api/v1.

On this page

Versioning

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.

fieldonwhat it says nowfuture removal
zones[].hex/irrigation/snapshotalways ""; the original deployment’s OpenSprinkler MAC suffixdropped
iu_enabled/irrigation/snapshotalways false; Irrigation Unlimited is gonedropped
iu_suspended/irrigation/snapshotalways false; the engine’s hold is skip_check.is_paused and pause_until_epochdropped
ha_reachable/irrigation/snapshotlast Home Assistant poll succeeded; always true on the native pathdropped; per-source reachability is GET /health sources[].status, the snapshot’s age is last_refresh_epoch
override_helpers_present/irrigation/snapshota persistence database is mounted (the name predates 0.7.22)dropped; controls_persisted says the same
ha_adoption_awaiting_config/irrigation/snapshotalways false since 0.9.0 removed the adoption passdropped
water_budgets[].mode_active/irrigation/snapshotalways truedropped
zones[].today_run_minutes/irrigation/snapshotalways null; nothing produces itdropped
/irrigation/shadow/snapshot, /irrigation/shadow/diffendpointsalways {"shadow":"disabled"}; shadow-native mode is goneabsent
deployment.shadow_nativeconfigread by nothingdropped
run_sequence_nowPOST /irrigation/action kindunknown since 0.9.0 (answers 400)absent
POST /wizard/test_sourceendpointstructural validation only; nothing in the UI calls itabsent

Migration notes

2.1.0 (local 0.9.0 completion candidate). 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 (local 0.9.0 candidate). 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 objectNewly 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 forecastwind_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_checkforecast_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.kc comes 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 HA input_number helpers outrank LocalSky’s own weekly_budget_in and sessions_per_week.
  • The 24-hour rain-defer gate weights forecast rain by precipitation probability, and reads the configured engine.session_rain_defer_in instead of a compile-time constant. Both make water_budgets[].today_seconds and today_reason move for the same weather.
  • A smart-morning dispatch that fails now writes a skipped run row carrying the controller’s error text, so GET /api/v1/history/runs shows 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 == true with running_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 as mqtt_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_week is constrained to 1..=7. PUT /api/config answers 422 with the new validation error zone_sessions_per_week_range, and POST /api/v1/config/zones/apply refuses an out-of-range value the way it refuses other out-of-band fields. Sessions space at floor(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_binding now reports that the per-run ceiling is what set tonight’s minutes: scheduled_seconds equals max_duration_seconds and some stage wanted more than that (the weekly allocator’s ideal session, the seasonal dial, or a condition-rule multiplier). It is false whenever 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 was raw_seconds > max_duration_seconds, and raw_seconds came only from the Smart Irrigation soil deficit, so it could go true only on a Home Assistant install carrying sensor.smart_irrigation_<slug>; a standalone install read the absent entity as a 0.0 deficit and the field was always false.

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.

GET /api/v1/info

Returns the running service version, compiled source revision, API contract version, and mount prefix. Hit it first when probing a LocalSky instance. Always public, even when authentication is required. build_revision is the exact source commit when the build supplies GIT_SHA, otherwise dev.

{
  "service": "localsky",
  "service_version": "0.7.0",
  "build_revision": "example-source-revision",
  "api_version": "2.1.0",
  "api_prefix": "/api/v1",
  "license": "Apache-2.0",
  "repository": "https://github.com/silenthooligan/localsky",
  "dry_run": false,
  "demo": false,
  "auth_required": true,
  "uuid": "1f0a4c2e-9b7d-4e21-a3c5-08d2f6b7e914",
  "has_irrigation": true,
  "nerd_mode_default": false
}
  • auth_required tells a client whether it must present credentials before touching anything else. Integration clients (the HACS integration) read this on probe and prompt for an API token.
  • uuid is the stable per-install id, also broadcast in the mDNS TXT record (_localsky._tcp.), so clients can dedupe an instance across IP or hostname changes.
  • dry_run and demo flag instances running with LOCALSKY_SMART_DRY_RUN=1 or LOCALSKY_DEMO=1.
  • has_irrigation is true when any controller or zone is configured; a weather-only install reads false, and the UI hides the irrigation navigation on it.
  • nerd_mode_default is the server-configured features.nerd_mode_default; the UI seeds Simple vs Nerd presentation from it.

Authentication

LocalSky ships built-in authentication (API 1.6.0+). It is policy-driven: [auth] mode = "disabled" (the default for upgraded installs) leaves every endpoint open, mode = "required" gates everything except the public set below. See the Authentication guide for setup, accounts, and trusted_networks.

Credentials

When auth is required, the middleware accepts credentials in this order:

  1. Authorization: Bearer lsk_...: a long-lived API token created under Settings, then Account. This is what integrations (HACS, scripts, dashboards) should use.
  2. ?access_token=lsk_...: the same API token as a query parameter, accepted only on paths ending in /stream (browser EventSource cannot set headers). It is ignored everywhere else.
  3. Session cookie: localsky_session=lss_..., set by POST /api/v1/auth/login. HttpOnly, SameSite=Lax, marked Secure when the request arrived over HTTPS (detected via X-Forwarded-Proto). Lifetime is session_ttl_days.

Requests from a trusted_networks CIDR skip credentials entirely; read how the client address is determined before relying on this.

Unauthenticated outcomes: HTML GETs are redirected (302) to /login; API calls get 401 with body {"error": "unauthorized"} and a WWW-Authenticate: Bearer realm="localsky" header.

Public paths

These are exempt from authentication, straight from the middleware’s exemption table:

PathWhy it is public
/pkg/*, /sw.jsCompiled hydration assets and the service worker; browsers fetch these without credentials, so gating them breaks the app
Root-level static files (/favicon.ico, /manifest.webmanifest, and any single-segment path ending in .svg .png .ico .webmanifest .woff2 .woff .css .js .map .txt)Browsers fetch manifests and icons without credentials. Uploaded photos under /site/photos/* stay protected
/api/v1/info, /api/infoPairing probe; carries auth_required so clients know to ask for a token
/login, /api/v1/auth/status, /api/v1/auth/login, /api/v1/auth/setup (and the /api/auth/* aliases)The way in. setup only succeeds while zero accounts exist
/ingest/*, /api/v1/ingest/*Weather hardware (Ecowitt consoles, webhook devices) cannot authenticate. See what to expose through a proxy
/api/v1/health, /api/healthAlways reachable for Docker healthchecks, but anonymous callers get a trimmed liveness-only body (no source, controller, or HA detail)
/metricsPrometheus exposition endpoint. Aggregate operational counters only (verdict mix, refresh and degraded counts, controller/cloud error counts, last-fetch latency); no secrets, config, or PII. Firewall it at the proxy if you do not want it public
/docs/*The bundled handbook (served from the image), so in-app help works pre-login and on a fresh install. Static pages, no secrets
/setup, /setup/*, /api/v1/wizard/*, /api/wizard/*Only until the first account exists, so docker run -> browser -> wizard works; locked once setup completes

Everything else, including every other /api/v1/* endpoint, the dashboard pages, and /site/photos/*, requires credentials.

Cross-origin behavior

LocalSky sends no CORS headers, so browsers block cross-origin reads of the API by default; call it from the same origin or from server-side code. Additionally, when auth is required, any non-GET request whose Origin header disagrees with the Host header is rejected with 403 (CSRF hardening alongside the SameSite=Lax cookie). Non-browser clients send no Origin header and pass.

Auth endpoints

EndpointMethodPurpose
/api/v1/auth/statusGET{ mode, setup_complete, authenticated }; always public
/api/v1/auth/setupPOSTCreate the first owner account {username, password}; 409 once one exists
/api/v1/auth/loginPOSTSign in {username, password}; sets the session cookie
/api/v1/auth/logoutPOSTClear the session
/api/v1/auth/sessionGETCurrent user (401 when anonymous and auth is required)
/api/v1/auth/tokensGET / POSTList / create API tokens ({name} -> {token}, shown exactly once)
/api/v1/auth/tokens/{id}DELETERevoke a token

Login and setup are rate limited to 10 attempts per minute per client address.

Snapshot endpoints (read-only)

These serve the dashboard’s primary data. Both REST (one-shot) and SSE (push-on-change) variants exist for every snapshot type. All SSE feeds emit events named snapshot. The weather (/api/v1/stream) and irrigation (/api/v1/irrigation/stream) feeds send a keep-alive every 15 seconds; the forecast feed (/api/v1/forecast/stream) sends one every 30 seconds.

GET /api/v1/snapshot

Current Tempest weather snapshot, the merged live observation set:

{
  "last_packet_epoch": 1765400000,
  "air_temp_f": 87.2,
  "feels_like_f": 91.4,
  "dew_point_f": 71.3,
  "wet_bulb_f": 75.1,
  "rh_pct": 65.0,
  "pressure_inhg": 30.05,
  "pressure_trend_inhg": [30.02, 30.03, 30.05],
  "wind_lull_mph": 1.2,
  "wind_avg_mph": 4.5,
  "wind_gust_mph": 8.1,
  "wind_dir_deg": 218.0,
  "rapid_wind_mph": 5.0,
  "rapid_wind_dir": 220.0,
  "illuminance_lx": 80500.0,
  "uv_index": 7.5,
  "solar_w_m2": 712.3,
  "rain_in_last_min": 0.0,
  "rain_in_today": 0.0,
  "rain_intensity_in_hr": 0.0,
  "precip_type": 0,
  "lightning_count_last_min": 0,
  "lightning_strikes_last_hour": 0,
  "lightning_recent": [],
  "lightning_avg_dist_mi": null,
  "last_strike_distance_mi": null,
  "last_strike_epoch": null,
  "battery_v": 2.78,
  "battery_pct": 92.0,
  "station_serial": "ST-00012345",
  "hub_serial": "HB-00067890"
}

GET /api/v1/stream

Server-Sent Events feed; one event per snapshot mutation. Use from a browser or any SSE client:

const es = new EventSource('/api/v1/stream');
es.addEventListener('snapshot', (e) => {
    const snap = JSON.parse(e.data);
    // ...
});

External SSE consumers on an auth-required instance append ?access_token=lsk_....

GET /api/v1/irrigation/snapshot

Current irrigation state. Top-level fields:

{
  "last_refresh_epoch": 1765400000,
  "ha_reachable": true,
  "tempest_last_seen_epoch": 1765399990,
  "forecast_last_seen_epoch": 1765398000,
  "next_run_epoch": 1765432800,
  "next_run_total_minutes": 62,
  "master_enable": true,
  "iu_enabled": true,
  "iu_suspended": false,
  "water_level_pct": 100.0,
  "zones": [ { "..." : "per-zone status, bucket, planned and last run, math" } ],
  "skip_check": { "...": "today's verdict inputs and result" },
  "forecast": { "...": "the forecast slice the engine used" },
  "seven_day_verdicts": [ ],
  "soil_forecasts": [ ],
  "water_budgets": [ ],
  "pause_until_epoch": 0,
  "override_tomorrow": "none",
  "override_helpers_present": true,
  "decision_trace": { "...": "why the verdict is what it is" },
  "zone_verdicts": [ ]
}

GET /api/v1/irrigation/stream

SSE feed for irrigation state. Same event mechanics as /api/v1/stream but emits on irrigation-snapshot changes.

GET /api/v1/forecast/snapshot

Daily and hourly Open-Meteo forecast slice currently in use. Returns the source’s last successful fetch.

GET /api/v1/forecast/stream

SSE feed for forecast snapshot changes.

GET /api/v1/forecast/bias

The learned per-month forecast bias multiplier, available once enough observations have been recorded.

Configuration endpoints

Always mounted. Until the wizard writes /data/localsky.toml, GET /api/v1/config returns the env-compat-synthesized baseline (lat/lon from env vars, default sources, no controllers configured).

GET /api/v1/config

Current config as JSON, with secrets redacted. Every known secret-bearing string (API keys, bearer tokens, controller passwords, and similar) is replaced with the sentinel ***redacted*** on the wire. The PUT handler accepts the sentinel back and preserves the stored value, so a GET-edit-PUT round trip never needs to know the real secrets.

GET /api/v1/config/schema

JSON Schema generated from the Config struct via schemars. Use this from any tool that wants to render config forms or validate user input client-side.

curl http://localhost:8090/api/v1/config/schema | jq '.properties.deployment'

PUT /api/v1/config

Replace the entire config. Body is a JSON object matching the schema. The server validates structurally (serde decode) and semantically, snapshots the previous config (retention: last 20 versions), writes /data/localsky.toml, and hot-reloads the runtime.

Returns 200 with { "saved": <version info>, "validation": <report> } on success (the report can carry non-blocking warnings); 422 with { "error": "config_invalid", "validation": <report> } on validation failure (the on-disk file is untouched).

curl -X PUT http://localhost:8090/api/v1/config \
    -H 'Content-Type: application/json' \
    -H 'Authorization: Bearer lsk_...' \
    -d @new-config.json

GET /api/v1/config/validate

Structured validation report (errors + warnings) for the config as currently on disk. Returns an empty report with a note when no config exists yet (wizard pending).

POST /api/v1/config/preview

Dry-run validation. Body: { "candidate": <Config JSON> }. Runs validation and returns { "ok": true|false, "errors": [...] } without writing anything. Useful for client-side “validate before save” flows.

GET /api/v1/config/snapshots

The on-disk config snapshot history, newest first. Every save snapshots the previous localsky.toml (newest 20 kept). Returns { "snapshots": [ { "ts", "applied_at_epoch", "schema_version", "note" }, ... ] }. (GET /api/v1/backup/snapshots returns the same history.)

POST /api/v1/config/rollback

Restore a previous snapshot. Body { "ts": <snapshot ts> } (the legacy ?to=<ts> query is also accepted). The snapshot is validated before the swap, the current config is snapshotted first so the rollback is itself reversible, and the restored config hot-reloads. Reachable even when the engine is degraded; use it to recover from a bad config push.

curl -X POST -H 'Authorization: Bearer lsk_...' \
    -H 'Content-Type: application/json' \
    -d '{"ts": 1765400000}' \
    http://localhost:8090/api/v1/config/rollback

POST /api/v1/config/zones/apply

Write one tuning report recommendation through the validated config path. Body: { "zone_slug", "recommendation_id", "field", "value", "window_days" }, echoing the recommendation as served. window_days is the window the report was fetched at (clamped 7..30; absent = the default 14): the server re-derives the zone’s recommendation at that window against the exact config it is about to mutate, inside the config write lock, and answers 409 { "error": "stale_recommendation" } when the claim no longer derives, so a stale page can never write an outdated value. A client viewing a non-default window MUST echo the report’s window_days or its applies can 409 indefinitely. Companion fields ride server-side (a measured precip_rate_mm_hr also stamps precip_rate_source = "measured"). The mutation runs the same validation as PUT /config (422 with the structured report on failure), snapshots the previous config, saves, hot-reloads the runtime, and returns { "applied", "zone", "field", "old_value", "new_value", "saved", "validation", "restart_required", "restart_reasons" }. Privileged like every config write.

curl -X POST http://localhost:8090/api/v1/config/zones/apply \
    -H 'Content-Type: application/json' \
    -H 'Authorization: Bearer lsk_...' \
    -d '{"zone_slug":"back_yard","recommendation_id":"8f2c41a09b6d13e7","field":"precip_rate_mm_hr","value":18.0,"window_days":14}'

GET /api/v1/config/raw and PUT /api/v1/config/raw

Read and write the raw TOML text instead of the JSON projection, for operators who prefer editing localsky.toml directly through the Settings raw editor.

GET /api/v1/config/field_sources

The dataset behind the Data sources page: the user-facing fields with a per-field picker (user_fields), every enabled source with the fields it can provide plus its tier (device / cloud), data nature, and region priority (sources), the saved per-field pins and ordered chains (overrides, field_source_chains), the forecast-capable candidates and the saved pin (forecast_candidates, forecast_provider), and a region_label for the “Automatic (region default)” tag. A field absent from both overrides and field_source_chains uses the automatic region order (sort that field’s candidates by region_priority descending). This is the read side of the chain editor; writes go through the normal config PUT.

GET /api/v1/config/source_catalog

The honest cloud-source catalog behind the cloud weather panel: one entry per cloud weather kind (highest honesty first), each carrying the static facts (data nature per field, key tier, real-time / localization / watering-risk copy, honesty and irrigation ranks), the live current-field list, region recommendation flags, whether the kind is already configured, and a live status computed by the same taxonomy as /api/v1/health (active / watching / standby / falling_through / offline). Top-level shape: { "lat": ..., "lon": ..., "cloud_sources": [ ... ] }.

Wizard endpoints

Used during first-run; always mounted, and public only until the first account exists (see Public paths). The dashboard routes to /setup when no /data/localsky.toml exists.

EndpointMethodPurpose
/api/v1/wizard/draftGET / PUT / DELETERead, save, or discard the wizard draft
/api/v1/wizard/applyPOSTValidate the draft and write it as the live config
/api/v1/wizard/stateGETWizard progress state
/api/v1/wizard/seed_currentPOSTSeed the draft from the current live config (re-running the wizard)
/api/v1/wizard/test_sourcePOSTDeprecated since 0.9.0 (see the table above). { "source": <SourceEntry> }; structural validation only, answers ok for any well-formed entry. Receiver sources confirm via live readings on the Sensors hub, polled sources within one cycle after apply
/api/v1/wizard/test_controllerPOST{ "controller": <ControllerEntry> }; live connect + status read. Returns { ok, reachable, master_enabled, water_level_pct, zone_count, firmware }, 502 if unreachable, 422 if unsupported. Rachio entries add discovered_device (the account’s first device, resolved when the entry has a token but no device id) and rate_limit_remaining; redacted secrets in the posted entry are restored from the stored config by entry id (400 unmatched_redacted_secret when unresolvable)
/api/v1/wizard/test_llmPOST{ "llm": <LlmConfig> }; live probe of the configured LLM provider
/api/v1/wizard/scan_zonesPOST{ "controller": <ControllerEntry> }; zone discovery for controllers that support it. Returns { "zones": [ { "station_id", "name" } ] }. Callers use it to offer the controller’s own zones as choices: the zone editor’s station picker, the controller editor’s bind table, and the setup wizard’s zone import. A redacted secret in the posted entry is restored from the stored config by entry id (400 unmatched_redacted_secret when no stored value matches). 422 controller_unsupported when the kind is not probeable at all (mqtt_command, ha_service_call, esphome_native); 502 zone_scan_failed when the controller is unreachable, rejects the credential, is rate limited, or has no zone-discovery endpoint (hydrawise, bhyve, rainbird, whose detail reads “operation not supported by this controller”). A client cannot tell “this kind cannot enumerate” from “this controller is offline” by status alone, so gate on the kind before calling: only rachio, opensprinkler_direct, http_generic and dry_run can enumerate
/api/v1/wizard/probe_soilPOST{ "host": "<gateway host>", "source_id": "..." } (source_id optional); reads an Ecowitt gateway’s live soil channels off its local API so the Sensors step can offer them for zone binding. 422 for an empty or non-LAN host, 502 if unreachable
/api/v1/wizard/discoverGETOne LAN sweep: passive Tempest, Ecowitt broadcast, OpenSprinkler probe
/api/v1/wizard/geocode?q=<address>GETServer-side proxy to Nominatim with the required User-Agent

geocode returns up to 5 candidates:

[
  {
    "display_name": "Orlando, Florida, USA",
    "lat": "28.5383",
    "lon": "-81.3792"
  },
  {
    "display_name": "Cambridge, Cambridgeshire, England, United Kingdom",
    "lat": "52.2053",
    "lon": "0.1218"
  }
]

Irrigation control endpoints

POST /api/v1/irrigation/action

Dispatch a controller action. The body is a tagged enum; shape varies by kind:

{ "kind": "run", "zone": "back_yard", "seconds": 600 }
{ "kind": "stop", "zone": "back_yard" }
{ "kind": "stop_all" }
{ "kind": "set_threshold", "key": "max_wind_mph", "value": 12.0 }
{ "kind": "toggle", "key": "irrigation_pause", "on": true }
{ "kind": "set_pause_until", "epoch": 1765500000 }
{ "kind": "clear_pause_until" }
{ "kind": "set_override_tomorrow", "mode": "skip" }
{ "kind": "set_global_override", "mode": "run" }
{ "kind": "set_zone_override", "zone": "back_yard", "mode": "skip" }

Notes:

  • run is clamped server-side to 7200 seconds (2 hours) regardless of what the client sends.
  • set_threshold accepts only the known keys max_wind_mph, min_temp_f, rain_skip_in, and writes engine.skip_rules, the same field Settings > Skip rules writes. Range-checked: 0 to 50 mph, 20 to 70 F, 0 to 10 in. On a Home Assistant deployment whose matching input_number.irrigation_* helper has not been retired yet it still writes that helper, because until then that is what the engine reads.
  • toggle takes irrigation_pause or irrigation_dry_run and writes LocalSky’s own control store, or the matching input_boolean on a Home Assistant deployment that has not retired it yet. Requires a persistence DB on the native path; 503 without one.
  • set_override_tomorrow takes "none" | "skip" | "run". It is a one-day override that LocalSky expires at local midnight itself; no Home Assistant midnight automation is involved.
  • set_pause_until with epoch: 0 clears the vacation pause (same as clear_pause_until).
  • set_global_override takes "auto" | "skip" | "run". It is a sticky global override, LocalSky-native (its own state, no nightly reset): "run" forces watering past the skip conditions, "skip" force-skips, "auto" follows the engine. It stays in effect until you change it.
  • set_zone_override takes a zone slug plus "auto" | "skip" | "run", the same sticky semantics scoped to one zone. A zone override beats the global override; "auto" clears the zone override so the zone falls back to the global override, then the engine verdict.
  • The two override actions are written to LocalSky’s own state (a small SQLite store) whenever a persistence DB is mounted, on both deployment paths. On a Home-Assistant-sourced deployment the engine does not read them back, so a Skip or a Force stored there decides nothing: the snapshot reports auto and the yard waters on the engine verdict. That is a known defect, it predates 0.7.22, and it is fixed separately. On a standalone deployment both work as described.

A successful run, stop, or stop_all dispatched through a configured controller answers 200 with { "ok": true, "dispatched": "controller:<id>", ... } plus confirm_within_s (1.23.0): how many seconds the dispatching controller can take to report the change, or null when it reads state on demand. A cloud controller polls its vendor on a throttle, so a change can be accepted well before it reads back; treat 200 as confirmation that the controller took the command, and confirm_within_s as how long the state may lag. (The legacy Home-Assistant-service-call path, used only on an HA-sourced deploy with no controller configured, omits the field; treat an absent value the same as null.)

A failed dispatch answers { "error": "<what happened>", "code": "<stable discriminator>" } plus, for the recoverable ones, a hint naming the fix. Branch on code, which is stable across status changes:

StatuscodeMeans
400zone_unknownThe zone is not mapped to a zone on the controller. The body carries mapped_zones, the controller’s zone-map keys, so a slug mismatch is visible directly
424controller_auth_failedThe controller rejected its own credential. Deliberately not 401, which on any endpoint means this deploy’s authentication failed
429controller_rate_limitedThe controller is rate limiting. The body carries rate_limit_remaining, what its last response reported (null when that response reported none)
501controller_unsupportedThe controller does not support the operation
502controller_unreachableThe controller returned an error, was unreachable, is offline, or its adapter failed to initialize. The error string carries the upstream status and body for a cloud controller
503(none)No controller registry, or no controller configured

A cloud controller’s zone map is keyed by the slugified vendor zone name, while dispatch looks it up by the LocalSky zone slug, and nothing forces the two to agree. That is what mapped_zones exists for: on a zone_unknown it shows exactly which keys the controller can dispatch, next to the slug that missed. The lookup is deliberately exact, with no name-similarity fallback, because guessing which valve a near-match meant risks opening the wrong one.

run_sequence_now (the Irrigation Unlimited sequence action) is gone. From 0.9.0 the kind is unknown to the parser and answers 400; use a per-zone run instead.

GET /api/v1/health?strict=1

/api/v1/health always answers 200 with the status in the body (ok, degraded, wizard). Add ?strict=1 and the status code follows the status: 503 unless it is ok, for monitors that alert on the code alone. location_configured says whether a place is set; each source carries a note when LocalSky is not polling it by its own choice.

GET /api/v1/diagnostics

One JSON bundle for a bug report: info, health (full detail), config with secrets redacted, the current decision_trace, and logs (the last 300 lines). Every secret value the config redaction knows is scrubbed from the whole bundle, log lines included. Privileged like the config surface.

GET /metrics

Prometheus exposition. Besides the refresh, verdict and controller counters, every poll-style source records localsky_source_fetch_total{source,outcome} and the latency histogram localsky_source_fetch_seconds{source}. LOCALSKY_LOG_FORMAT=json switches the process log to one JSON object per line.

The local 0.9.0 snapshot also adds water_plan[]: a progressive daily scenario with date, optional start/finish, forecast and expected rain, evidence quality, and per-zone runtimes, reasons, depletion bounds and plant demand. Forecast water in these rows never becomes an observed ledger entry. When no positive run is projected, next_run_state is no_water_planned, next_run_epoch is zero and next_run_day_offset is null. A zero-minute finish boundary is not a watering start. water_budgets[].soil_depletion_range_mm preserves uncertain initial-state bounds; soil_depletion_mm remains null until the point converges.

GET /api/v1/irrigation/history?days=30

Run and daily-decision history. days defaults to 30; days=0 returns all retained records. Positive ranges are bounded to 36,500 days. The chart and accuracy endpoints keep their own shorter limits.

{
  "from_epoch": 1762808000,
  "to_epoch": 1765400000,
  "runs": [
    { "zone": "back_yard", "start_epoch": 1765320000, "duration_s": 600, "skip_reason": null, "source": "ha_refresher", "status": "completed" }
  ]
}

Rows with a non-null skip_reason are skip events. Delivered water is recorded by completed or aborted runs, including smart_morning, manual and observer sources. Use session identities and interval unions to avoid counting the request and hardware observation twice. dry_run records never represent applied water.

The additive daily array contains { date_local, epoch, kind, zones }. Each zone carries its slug/name, planned seconds, reason code, reason and water-need explanation. kind is scheduled, scheduled_legacy, missed_window, or recorded_decision. These are planning evidence; actual run rows determine water delivered. Generic legacy weather holds cannot prove a valve dispatch was skipped.

GET /api/v1/irrigation/decisions?days=30

Verdict-transition history: records changes in the continuously evaluated engine verdict. days defaults to 30 and clamps to 1..365. These records describe the engine’s decision at the recorded instant; the daily journal and run log provide the scheduled morning and delivery evidence.

GET /api/v1/irrigation/export?days=365&format=csv

Portable history export. format=csv (the default) streams the run/skip events as timestamp_utc,zone,event,duration_s,reason rows; format=json returns the full { from_epoch, to_epoch, runs, decisions } structure. days defaults to 365 and clamps to 1..3650. Served with a Content-Disposition: attachment header, so a browser hit downloads a file.

GET /api/v1/irrigation/accuracy?days=30

Only completed days in the deployment timezone enter the matched/scored tally. Today and future-dated records retain their forecast and observed-so-far values with correct: null until the local day ends; partial rain is never a final miss.

The forecast-accuracy scoreboard: one row per local day pairing that morning’s verdict with the rain that actually fell, plus the matched/scored tally. days defaults to 30 and clamps to 1..365. Like /history and /decisions, this mounts only when the history database is available.

GET /api/v1/irrigation/tuning?days=14

The per-zone tuning report: a window of recorded outcomes reduced to at most one plain-language recommendation per zone, plus the install-wide forecast-skip scorecard. days defaults to 14 and clamps to 7..30. Like /history and /decisions, this mounts only when the history database is available. Read-only; the write side is POST /api/v1/config/zones/apply.

{
  "generated_epoch": 1787500000,
  "window_days": 14,
  "zones": [
    {
      "slug": "back_yard",
      "display_name": "Back Yard",
      "status": "recommendation",
      "lines": ["Watered 5 time(s) in the last 14 days."],
      "recommendation": {
        "id": "8f2c41a09b6d13e7",
        "field": "precip_rate_mm_hr",
        "current_value": null,
        "suggested_value": 18.0,
        "companion_fields": [{ "field": "precip_rate_source", "value": "measured" }],
        "headline": "Set this zone's sprinkler rate to the measured 18.0 mm/hr; runs are planned as if it were 38.0 mm/hr.",
        "evidence": ["Median rate backed out of 3 clean watering events: 18.0 mm/hr vs the configured 38.0 mm/hr (53% apart)."],
        "confidence": "medium"
      }
    }
  ],
  "scorecard": {
    "window_days": 30,
    "scored_days": 4,
    "confirmed_days": 3,
    "min_scored_days": 3,
    "line": "Forecast rain prompted hold verdicts on 4 days in the last 30; rain followed on 3 of 4.",
    "reactive_days": 2,
    "reactive_line": "Hold verdicts for rain already falling or on the ground: 2 days in the last 30."
  }
}
  • status is recommendation, ok, or insufficient_data; lines carries the cadence line, the water-balance term lines (observed rain, applied irrigation, forecast credit, each with its source rung), and each check’s specific not-enough-data state.
  • current_value / suggested_value are JSON values; null as a suggestion means “clear the override” (restore the default).
  • The scorecard reduces decision history to the first recorded verdict per configured local day. It evaluates rain-related hold verdicts, not actual watering or skipped automatic runs; use the run log for recorded outcomes. scored_days / confirmed_days cover forecast rain (within 4 hours, tomorrow, or 3 days) and are null until at least min_scored_days can be judged. Reactive holds for rain already falling or on the ground are counted separately in reactive_days / reactive_line (null / empty until one exists).
  • Applying a recommendation from a non-default window must echo the report’s window_days (see the apply endpoint below): window-dependent checks derive different suggestions at different windows.
  • dismissed: true (1.21.0) marks a zone with at least one snoozed or dismissed suggestion. The silenced suggestion is skipped inside the ranked pick, so a lower-ranked suggestion may still occupy recommendation; the last entry of lines is the muted annotation, and dismissed_fields names the silenced config fields for the undismiss call.

POST /api/v1/irrigation/tuning/dismiss and POST /api/v1/irrigation/tuning/undismiss

Silence or restore one zone’s tuning recommendation (1.21.0). Dismiss body: { "zone_slug", "field", "recommendation_id", "kind" } where kind is "snooze" (silences the exact recommendation_id for 30 days; a suggestion whose value later drifts to a new id returns immediately) or "permanent" (keys the zone + field and survives value drift). Undismiss body: { "zone_slug", "field" }; answers { "removed": N }. Both are privileged like POST /config/zones/apply. Silencing is per-suggestion and total for that suggestion: the report strips it server-side inside the ranked pick (the zone’s next-ranked suggestion, if any, surfaces instead), so every count-based surface and the weekly notification go quiet with it; a report whose every suggestion is silenced sends no weekly push. Mount only when the history database is available.

POST /api/v1/irrigation/simulate

What-if evaluation of the skip-check against a supplied scenario, without touching hardware.

GET /api/v1/irrigation/shadow/snapshot and GET /api/v1/irrigation/shadow/diff

Deprecated since 0.9.0. Shadow mode (the native snapshot built beside the Home Assistant one) is gone; both routes keep their shape and always answer {"shadow":"disabled"}. See the deprecation table above.

GET /api/v1/irrigation/explanation

Latest LLM-generated plain-English explanation of today’s verdict. Cached for 5 minutes.

GET /api/v1/irrigation/anomalies

Latest LLM-generated anomaly list. Cached for 1 hour.

{
  "anomalies": [
    {
      "severity": "warn",
      "type": "soil_moisture_drift",
      "description": "Back yard moisture has dropped 18% in 24h, faster than ETc alone predicts."
    }
  ]
}

Devices

GET /api/v1/devices

Every gateway, hub, controller, and cloud account LocalSky knows about, each with the sensors or zones it provides (the MA-style device view). Sorted by id.

GET /api/v1/devices/discover

Broadcast LAN discovery (Ecowitt gateways today). Listens for about 3 seconds and returns the gateways found, each with a suggested host the UI pre-fills into an ecowitt_gw_poll source.

Sensors and weather history

These endpoints are mounted only when the history database is available (it is, in any normal Docker deployment with /data mounted).

EndpointMethodPurpose
/api/v1/sensors/soilGETSoil-moisture channels for the zone picker
/api/v1/sensors/discoveredGETEvery relevant entity LocalSky can see, grouped by role (HA entities as ha:<entity_id>, local POST channels as source:<src>:<key>)
/api/v1/sensors/manifestGETDeclarative entity inventory for the HACS integration
/api/v1/weather/history?hours=24GETRecent observed-weather series (oldest to newest) for the headline fields; powers the dashboard sparklines
/api/v1/weather/readingsGETRecent raw readings from the sensor-history table

Radar map data

Server-side data services for the radar map’s overlay layers. Canonical prefix only (/api/v1/radar/*, no legacy /api alias). All three are built from upstream feeds with server-side caching, so map panning does not hammer the upstreams; on an upstream failure they return 502 and the frontend degrades the layer silently.

EndpointMethodPurpose
/api/v1/radar/windgrid?bbox=minLon,minLat,maxLon,maxLatGETWind field for the leaflet-velocity layer: a grib2json-style two-record array (U then V components in m/s) over an 8x8 grid clamped to the bbox. Cached about 30 minutes
/api/v1/radar/precip?bbox=minLon,minLat,maxLon,maxLatGETShort-range precipitation nowcast grid: 8 future 15-minute frames (the next 2 hours) of mm-per-15-min values over the same 8x8 grid, plus a max_mm scale hint. Cached about 15 minutes
/api/v1/radar/tropicalGETBasin-aware tropical cyclone GeoJSON, normalized from the NHC/CPHC, JMA, and JTWC feeds into one FeatureCollection (positions, tracks, forecast tracks, cones) plus a per-agency sources health array. Cached 10 minutes

Web Push endpoints

GET /api/v1/push/vapid-key

Public VAPID key for browser subscription. Returns { "public_key": "<base64url>" }, or 503 with { "error": "vapid not configured" } when no keypair is loaded. See Notifications for key generation.

POST /api/v1/push/subscribe

Body: the PushSubscription JSON from the browser’s pushManager.subscribe() ({ endpoint, keys: { p256dh, auth } }). Idempotent upsert; returns { "ok": true }.

POST /api/v1/push/unsubscribe

Body: { "endpoint": "..." }. Returns { "ok": true, "removed": <n> }.

Both subscribe endpoints return 503 if the history database was not openable at startup.

Zone photos

POST /api/v1/zones/photo

Multipart upload, field name file. Accepts jpg, jpeg, png, gif, webp up to 10 MB (SVG is rejected because it can carry script). Returns { "url": "/site/photos/...", "filename": "..." }. The served photos under /site/photos/* require authentication.

Ingest endpoints

Push-style sensor receivers. Mounted at /ingest/* and /api/v1/ingest/*, and unauthenticated by design because the posting hardware cannot hold credentials; per-source path secrets are the mitigation. Do not expose these to the internet: see what to expose.

EndpointMethodPurpose
/ingest/ecowittPOSTEcowitt console “custom upload” receiver (form-encoded)
/ingest/webhook/{id}POSTGeneric HTTP webhook receiver for the configured webhook source {id}

Both return 200 on successful parse so misconfigured downstreams do not trigger retry storms on the device.

Health and meta

GET /api/v1/health

Liveness + readiness, always reachable. Authenticated (or auth-disabled) callers get the full structured body:

{
  "status": "ok",
  "config_present": true,
  "version": "0.7.0",
  "schema_version": 2,
  "uptime_s": 1234,
  "subsystems": { "config_store": "ok", "persistence": "ok" },
  "sources": [
    {
      "id": "tempest",
      "kind": "tempest_udp",
      "enabled": true,
      "last_seen_epoch": 1765399990,
      "stale_for_s": 12,
      "status": "active"
    }
  ],
  "controllers": [
    { "id": "opensprinkler", "kind": "opensprinkler_direct", "default": true, "enabled": true }
  ],
  "ha": { "env_configured": true, "reachable": true, "snapshot_source": "standalone" }
}

Per-source status reflects each source’s role in the live per-field merge, not a raw age bucket. It is one of:

  • active: the source currently owns at least one live reading (it is the winning provider for a field right now).
  • watching: reachable and quiet. It fetched fine but has nothing to report this cycle (a dry or no-coverage rain authority), or it is a reachable non-owner whose reading is currently held only by a lower-or-equal-priority source. It should be winning and simply has nothing to add yet.
  • standby: reachable and owns nothing because a strictly higher-priority source currently owns the reading(s) it could provide. It is ready to take over if that source goes quiet.
  • falling_through: it previously owned a reading, has since gone stale past that reading’s freshness window, and another source has taken over (the backup chain handled it). Reserved: current releases do not yet assert this state (prior ownership is not tracked), so a source in that situation reads standby or watching instead; treat it as part of the vocabulary, not a status to alert on.
  • offline: no successful fetch and no observation for the hard-offline window (about 30 minutes), or never seen. This is the only status that marks the instance degraded; watching, standby, and falling_through are all calm (the fall-through chain working as designed).

last_seen_epoch and stale_for_s remain on the wire as diagnostics but no longer drive the status; the taxonomy above is computed from reachability plus live per-field ownership. On an auth-required instance, anonymous callers get a trimmed liveness-only body: no sources, controllers, or ha detail, so Docker healthchecks keep working without leaking topology.

When config_present is false the server is in wizard mode; the dashboard redirects to /setup.

GET /api/v1/updates

Release check status: { current, latest, update_available, release_url, checked_at_epoch, check_enabled }. The background check only runs when [updates] check_enabled is set; otherwise latest stays null. When enabled it fetches the project version manifest at localsky.io/latest.json daily; the running version travels in the request User-Agent, nothing per-install.

GET /api/v1/location

The configured map center (lat/lon/zoom) for the radar, from deployment.location in the config, falling back to the WEATHER_APP_LAT/WEATHER_APP_LON env vars.

GET /api/v1/location/timezone?lat=<lat>&lon=<lon>

Offline IANA timezone lookup for a coordinate.

GET /api/v1/location/elevation?lat=<lat>&lon=<lon>

Elevation lookup for a coordinate via the Open-Meteo elevation API, returning { "elevation_m": <meters> } (the same unit as the config’s deployment.location.elevation_m, which the wizard prefills from it). Returns 502 on an upstream or parse failure; the wizard falls back to manual entry.

GET /metrics

Prometheus exposition endpoint (text/plain; version=0.0.4), served at the origin root (not under /api/v1) and always public. Aggregate operational counters only: verdict mix, refresh and degraded counts, controller and cloud error counts, last-fetch latency. No secrets, config, or PII; firewall it at the proxy if you do not want it exposed.

System

POST /api/v1/system/restart restarts LocalSky from inside the app (privileged: same authentication bar as a config write). Body is optional: {"force": true} overrides the active-watering guard, which otherwise answers 409 watering_in_progress naming the running zones. Responds 202 {"mode": "supervisor" | "exit"}: under the Home Assistant add-on the Supervisor restarts the add-on; everywhere else the process exits cleanly and the container/service restart policy relaunches it (every documented install runs --restart unless-stopped or equivalent).

Backup and restore

EndpointMethodPurpose
/api/v1/backupGETtar.gz bundle: localsky.toml + a consistent copy of the database + manifest. Deliberately excludes the VAPID private key directory
/api/v1/backup/restorePOSTMultipart restore (bundle, or bare config / db); the database swaps in at next boot
/api/v1/backup/snapshotsGETConfig snapshot history feeding POST /api/v1/config/rollback

Service worker and PWA

GET /sw.js

Service worker script. Version interpolated server-side from CARGO_PKG_VERSION so every deploy bumps the SW version. Always public.

GET /manifest.webmanifest

PWA manifest. Static and always public.

Client tooling

A minimal Python client to round-trip the config:

import requests

base = 'http://localhost:8090'
headers = {'Authorization': 'Bearer lsk_...'}  # omit if auth is disabled

cfg = requests.get(f'{base}/api/v1/config', headers=headers).json()
# Secret fields arrive as "***redacted***"; leave them unchanged and
# the server preserves the stored values on PUT.

cfg['engine']['skip_rules']['max_wind_mph'] = 12.0

r = requests.put(f'{base}/api/v1/config', json=cfg, headers=headers)
if r.status_code == 200:
    print('saved', r.json()['saved'])
else:
    print('rejected:', r.json())

JavaScript / shell / Rust clients follow the same shape.

The irrigation snapshot’s additive current_weather map carries the selected current temperature (air_temp_f), sustained wind (wind_mph) and humidity (rh_pct), plus reported gust, lull, rapid wind and direction fields. Each entry includes value, source_id, observed_epoch, max_age_s, measured and selection_reason. Age uses the provider’s report time, not the HTTP poll. NWS station reports are measured despite arriving over the internet; modeled current intervals remain estimated. If a selected report expires, the engine can use available current-hour forecast fields and marks live_data degraded. Incomplete current evidence holds irrigation.

A populated current response supplies current_weather as an object; an empty object means no accepted reports. null or an absent field means the producer has not supplied this evidence (including older response versions).