LocalSky
These docs track LocalSky v0.7.4.
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 17-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 know | Chapter |
|---|---|
| What weather sources LocalSky can read | Weather and soil sensors |
| How the engine decides whether to water | Irrigation engine + Skip rules in depth |
| Which grass species the catalog supports | Grass species catalog |
| Which soil textures the catalog supports | Soil texture catalog |
| Which controllers LocalSky drives | Irrigation controllers |
| Every config option | Configuration reference |
| Every REST + SSE endpoint | REST + SSE API |
| Upgrade from v0.1 | Upgrading LocalSky |
| Something broke | Troubleshooting |
| Quick answers | FAQ |
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. The only outbound calls are the ones you opt into: public forecast sources (Open-Meteo, NWS, and others) and any cloud-backed controller you connect (Rachio, B-hyve, Hydrawise). A LAN-only setup with a local controller makes none.
Project links
- Source: github.com/silenthooligan/localsky
- HACS integration: github.com/silenthooligan/localsky-ha
- Issues + discussions: same repos
- License: Apache-2.0
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.)
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
/dataitself on startup, so a host directory (-v /opt/localsky/data:/data) works with no manualchown. The only requirement is that/datais writable, so do not mount it read-only.
Networking for LAN weather stations. On Linux,
--network hostis 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-pflags; 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/setup to start the first-run wizard. A fresh install does not redirect automatically, so go to /setup directly.
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.
- Welcome: what LocalSky is and the Apache-2.0 license acknowledgement. No telemetry, no analytics, no email signup.
- 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.
- 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.
- Controller: add your irrigation controller with the same editor as Settings, test it live against the real hardware, and scan it for zones. Scanned stations can be imported as zone stubs.
- Zones: explains LocalSky’s zone model and shows the grass-species gallery so you pick the right species. Zone editing itself lives under
/settings/zonesafter the wizard; zones imported from a controller scan arrive there pre-populated. - AI advisor (optional): pick an LLM provider, or None. You can test the connection live before finishing. See llm.md.
- Notifications (optional): Web Push, MQTT, ntfy, Slack. All independent; none required.
- 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.
- 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:
- Run the install command above.
- In the wizard’s Controller step, add your direct-controlled controller (OpenSprinkler is the canonical example) and test it.
- 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.
Path 2: HACS integration (recommended for HA users)
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:
- Same install command; configure your controller under
/settings/controllers. - Under Settings > Notifications, set the MQTT broker host, port, credentials, and discovery prefix, and leave publishing enabled.
- Settings > Home Assistant shows whether discovery is currently publishing.
- 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:
- In the wizard’s Controller step (or
/settings/controllers), pick theha_service_callcontroller type. - 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).
- 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 type | Unlocks |
|---|---|
| Soil moisture (Ecowitt WH51 / WH52, Aqara, Sonoff) | Per-zone saturation skip, soil-moisture projection, smarter dry-out detection |
| Soil temperature | Soil-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 detector | Powers 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
/setupor/settings/zones. - Verdict shows “(weather rules only; soil rules offline)”: a soil moisture probe isn’t reporting. Check the source under
/settings/sources. - LLM advisor is grayed out: provider is unreachable. Visit
/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 -mshould reportaarch64). 32-bit Pi OS is not supported.
Next steps
- docs/standalone.md: full no-HA setup including MQTT-based sensor ingestion
- docs/api.md: REST endpoints + SSE streams for configs and data
- docs/controllers.md: every supported controller in depth
- docs/irrigation-engine.md: FAO-56 math driving verdicts
- docs/grass-species.md: species catalog
- docs/skip-rules.md: every rule in the ladder
- docs/configuration.md: field-by-field config reference
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
- 17-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 via /settings/sources once the editor lands):
[[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/+/SENSORmatches 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"readsobj["soil"]["moisture"]"sensors.0.value"readsobj["sensors"][0]["value"]
- Tasmota-style number-as-string payloads
- Linear transforms:
published_value * scale + offsetfor unit conversion or sensor calibration
Hardware that works this way
| Device | How it gets to MQTT | LocalSky path |
|---|---|---|
| ESPHome-flashed ESP32 + sensor | Native MQTT publish (or via HA’s MQTT integration) | Subscribe to esphome/<device>/<sensor>/state |
| Tasmota-flashed device | Native MQTT publish | Subscribe 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 sidecar | Subscribe to ecowitt/<device_id> |
| Shelly devices | Native MQTT (firmware setting) | Subscribe to shellies/<device>/<field> |
| Arbitrary Arduino / Pi project | PubSubClient / paho-mqtt | Subscribe 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.
| Sensor | Adapter | Status |
|---|---|---|
| Tempest hub (UDP broadcast 50222) | tempest_udp | Shipped |
Ecowitt GW1100 / GW2000 (LAN push to /ingest/ecowitt) | ecowitt_local | Shipped |
| Ecowitt GW1100 / GW2000 (native LAN poll, incl. per-channel soil calibration) | ecowitt_gw_poll | Shipped |
| Ambient Weather (cloud REST) | ambient_weather | Shipped |
| 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:
OpenSprinkler (the recommended hardware)
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:
- Smart Irrigation (HACS) does ET₀ + per-zone bucket + Kc + planned-run-seconds. LocalSky’s engine/et0.rs + engine/water_balance.rs + engine/species_catalog.rs do the same thing with the same FAO-56 math.
- Irrigation Unlimited (HACS) does schedule sequencing + zone dispatch. LocalSky’s engine/skip_rules.rs + engine/budget.rs + the controller HAL do the same thing.
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:
| Objection | LocalSky 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
| Capability | Standalone | HA + LocalSky (Mode 2: outbound) | HA + LocalSky (Mode 3: HA-driven) |
|---|---|---|---|
| Weather dashboard | ✅ | ✅ | ✅ |
| Engine (ET, bucket, 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 surface | LocalSky /settings | LocalSky /settings | LocalSky /settings |
| LocalSky depends on HA | No | No | Yes (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:
| Piece | What it is | Works on |
|---|---|---|
| This app | The LocalSky server: data collection, irrigation engine, web UI | Home Assistant OS / Supervised only |
| HACS integration | The bridge that turns a running server into HA entities | Every HA installation type |
| Docker install | The same server, run anywhere Docker runs | Any 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
amd64oraarch64machine (Raspberry Pi 4/5, ODROID, generic x86) - A free TCP port 8090 on the host
Install
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
-
Start the app, then click OPEN WEB UI (the UI listens on port 8090).
-
The first-run wizard walks you through station setup (Tempest or Ecowitt), location, zones, and your irrigation controller, exactly as in the Quick start.
-
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:
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
| Option | Default | What it does |
|---|---|---|
home_assistant | on | Connect to HA through the Supervisor (device import, entity blending) |
log_level | info | Server 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/infoand 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 retainedhomeassistant/.../configdiscovery 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/infoduring 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. Add the custom repository
The integration is not yet in the HACS default catalog, so this step is required first. The button does it in one click:
Or manually:
- In Home Assistant, open HACS.
- Open the three-dot menu (top right) and choose Custom repositories.
- Add
https://github.com/silenthooligan/localsky-hawith category Integration. - Search for LocalSky in HACS and install it.
- Restart Home Assistant.
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:
- Settings > Devices & Services > Add Integration, search for LocalSky.
- Enter the host (for example
10.0.0.100) and port (default8090).
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):
| Option | Default | Range |
|---|---|---|
| Use SSE push updates | on | on/off |
| Poll interval (fallback when SSE is off) | 30 s | 5 to 600 s |
| Default run duration for valve/switch open | 600 s | 60 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:
- In LocalSky, open Settings > Account.
- Under API tokens, create a token with a recognizable name (for example
home-assistant). - Copy the token; LocalSky shows it once.
- Paste it into the config flow’s token step. The integration validates it against
GET /api/v1/auth/sessionbefore 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.
| Sensor | Unit |
|---|---|
| Air temperature, feels like, dew point, wet bulb | °F |
| Humidity | % |
| Pressure | inHg |
| Wind speed, gust, lull | mph |
| Wind direction | ° |
| Solar irradiance | W/m² |
| UV index, illuminance | index, lx |
| Rain today, rain last minute, rain intensity | in, in/hr |
| Lightning strikes (last hour), average distance | count, mi |
| Station battery | % |
Irrigation
| Entity | Platform | Notes |
|---|---|---|
| Irrigation verdict | sensor | today’s run/skip verdict from the engine |
| Irrigation reason | sensor | the human-readable “why” behind the verdict |
| ET₀ today | sensor | mm |
| Days since rain | sensor | days since significant rain |
| Rain tomorrow probability | sensor | % |
| Heat multiplier | sensor | engine’s heat adjustment factor |
| Water level | sensor | controller water level % |
| Max wind, Min temp, Rain skip | number | skip-threshold sliders (0-50 mph, 20-60 °F, 0-1 in; about 0-80 km/h, -7 to 16 °C, 0-25 mm). These sliders do not convert to HA’s unit system; set them in imperial |
| HA reachable | binary_sensor | connectivity diagnostic |
| Irrigation suspended | binary_sensor | on while a pause is active |
| Any zone running | binary_sensor | on while any zone runs |
Per zone
| Entity | Platform | Notes |
|---|---|---|
valve.<zone> | valve | the canonical control: open = run (default duration from options), close = stop |
<zone> running | binary_sensor | device class running |
<zone> soil bucket | sensor | engine bucket state, mm |
<zone> soil moisture | sensor | live probe %, unavailable when no probe assigned |
<zone> soil temperature | sensor | °F, native Ecowitt probes |
<zone> soil EC | sensor | µS/cm, native Ecowitt probes |
<zone> soil battery | sensor | probe battery % |
<zone> planned run | sensor | seconds planned for the next run |
<zone> run today | sensor | minutes actually run today |
switch.<zone> run | switch | legacy shim, disabled by default; prefer the valve |
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.
| Service | Fields | Limits |
|---|---|---|
localsky.run_zone | zone (slug, required), seconds (required) | seconds clamped to 1-7200; LocalSky’s server enforces the same 2 hour cap |
localsky.stop_zone | zone (required) | |
localsky.stop_all | stops every running zone | |
localsky.pause | hours (default 24) | 1-720 hours; schedules and manual runs will not fire while paused |
localsky.resume | clears 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_callcontroller, 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. The stalesensor.localsky_*MQTT entities disappear after an HA restart. - Keep MQTT discovery: remove the LocalSky integration entry under Settings > Devices & Services.
Catalog status
Today the integration installs as a HACS custom repository. Submission to the HACS default catalog is planned and gated on a soak period for the custom-repo install path, LocalSky’s /api/v1/* surface being declared stable, and the integration’s test suite. Until then, the custom repository step above is required exactly once per HA install.
See also
- standalone.md: everything LocalSky does without HA
- migrating-from-ha.md: moving the watering brain out of HA
- api.md: the REST and SSE surface the integration consumes
- authentication.md: owner accounts and API tokens
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, per-zone soil buckets, skip rules, and the morning schedule.
- 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.
Phase 1: Stand LocalSky up next to what you have
Nothing breaks in this phase; you’re adding, not replacing.
- Install LocalSky (Docker or the binary) and run the setup wizard: location, weather sources, your controller, zones.
- 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.
- 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. Everything else (Tempest, Ecowitt, forecast models) comes in natively. See sensors.md for a worked example. - Install the LocalSky integration in HA, following
hacs.md. Two gotchas: it is not in the HACS default
catalog yet, so add
https://github.com/silenthooligan/localsky-haas a HACS custom repository first; and 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.
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’s per-zone water buckets start at field capacity on a fresh install (the model assumes the soil starts full), so the first plans can be smaller than what your old system would water until daily ET draws the buckets down to their real level. Don’t tune against day one; give the model 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
- Disarm the HA-side scheduler first so nothing double-waters:
- Irrigation Unlimited: turn off the controller master switch
(
switch.irrigation_unlimited_c1_m) or setenabled: falseon 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.
- Irrigation Unlimited: turn off the controller master switch
(
- In LocalSky, confirm the controller is enabled and every zone is mapped to a station.
- LocalSky schedules the next morning run automatically; the Irrigation tab shows when and why.
- 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:
| Piece | Behavior 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. When HA stops answering, the source is flagged unreachable and stops producing readings: the mapped fields simply stop updating, and the engine keeps computing from its remaining sources (your station and forecast models). A zone whose soil probe is an HA entity reads as probe offline and falls back to the modeled water bucket until HA returns. |
ha_service_call controller | Every 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.
-
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.
-
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.
-
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.
-
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.
-
Keep: the LocalSky integration, and the HA passthrough source only if it still feeds sensors that exist nowhere else.
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 had | LocalSky equivalent | Where it’s documented |
|---|---|---|
| Smart Irrigation ET calculations | Native ET engine (FAO-56, per-zone buckets) | irrigation-engine.md |
| Smart Irrigation seasonal adjustment | Kc curves per species + the engine’s heat multiplier | zone-math.md |
| Irrigation Unlimited schedules | Smart-morning scheduler + per-zone budgets | irrigation-engine.md |
| Irrigation Unlimited sequences | The morning run is a sequence: zones dispatch one after another, with cycle-and-soak splitting per zone | irrigation-engine.md |
| Multiple schedules per zone | Manual schedules alongside the smart scheduler, plus per-zone weekly budget and sessions-per-week | configuration.md |
| HA automations for rain skip | Skip rules + Rule Lab (Settings > Logic) | skip-rules.md |
| Vendor app weather skip | Forecast-aware verdicts, visible per zone | verdict-strip.md |
| Rain delay button | Pause/resume: the dashboard pause control or localsky.pause / localsky.resume from HA | hacs.md |
| Manual-run services / scripts | localsky.run_zone and localsky.stop_zone services, or open the zone’s valve entity | hacs.md |
| Zone switches in HA | valve.<zone> via the integration (a legacy switch shim exists, disabled by default) | hacs.md |
| “Is it running” sensors | Per-zone running binary_sensor via the integration | hacs.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:
- Click Scan network. LocalSky listens for supported gateways broadcasting on the LAN.
- Each gateway it finds shows up as a Discovered card with its model, IP, and MAC address.
- 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.
- 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 > Data sources 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: the plain-language, end-to-end walkthrough.
- Weather and soil sensors: the full catalog of what each sensor type unlocks.
- Irrigation controllers: every supported controller in depth.
- Forecast sources and merge: how multiple weather sources combine.
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:
- Ecowitt gateway on your LAN (recommended, and the cheapest). Native, no cloud, no broker.
- Any MQTT-published probe (ESPHome, Tasmota, Zigbee2MQTT, a DIY ESP32). Needs an MQTT broker.
- Any Home Assistant soil entity (a Zigbee or Z-Wave probe HA already knows about). Needs an HA bridge.
Path 1: Ecowitt gateway (recommended)
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:
- 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. - 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…).
- 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 thehostfield by hand.) - Save. LocalSky starts polling the gateway every 30 seconds.
- Go to Settings, Sensors. Each soil channel the gateway reports now appears as a card under the gateway, with its live moisture reading.
- 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:
- Publish soil moisture to a topic from your probe, for example
zigbee2mqtt/garden_soiloresp/soil/back. Note whether the payload is a bare number or a JSON object. - In LocalSky, go to Settings, Devices, click Weather source, and pick
MQTT. - Fill in the broker host, port, and credentials.
- 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.
- MQTT topic: the topic your probe publishes to (wildcards
- Save. The subscription starts immediately.
- 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:
- Create a long-lived access token in HA: your HA profile, Security, Long-lived access tokens, Create token. Copy it.
- In LocalSky, go to Settings, Devices, click Weather source, and pick
HA passthrough. - 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. - 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; the run sizes to the deficit as usual.
- 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 falls back to the modeled bucket automatically. Nothing blocks; a missing probe never stops a run. If a probe reads as a wild outlier versus its neighbours, 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
- Weather and soil sensors: the full catalog of what each sensor type unlocks.
- Soil probes and zones: the short reference for the binding model.
- Irrigation controllers: the supported controller list, including flow-meter support.
- Skip rules in depth: exactly how a soil reading changes a verdict.
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
mqttsource 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
- Single-bucket water balance with TAW + MAD-driven scheduling
- 17-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.
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 neighbours, is flagged on the irrigation and zones views so you know when to check the hardware.
Connect via: the native Ecowitt gateway poll, the Ecowitt LAN push receiver, 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 stops guessing: the probe’s reading sits alongside the modeled bucket as the zone’s gate.
Supported paths in:
- Ecowitt soil probes (WH51 and friends) via a LAN gateway: native, no cloud. The
ecowitt_gw_pollsource polls the gateway directly and records moisture, temperature, conductivity, and battery per probe; theecowitt_localpush receiver works too. - Any Home Assistant soil sensor entity: a Zigbee probe on ZHA, a Z-Wave probe, anything HA already knows about.
Assignment happens in the zone’s settings: Settings > Zones > pick the zone > soil sensor. One probe per zone. The picker lists every soil channel LocalSky has discovered: native gateway channels appear as source:<source_id>:soilmoisture<N>, HA entities 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; runs size to the deficit as usual.
- 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 falls back to the modeled bucket automatically; nothing blocks.
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: give LocalSky HA credentials. HA-backed sensing uses the HA_URL and HA_TOKEN (or HA_LONG_LIVED_TOKEN) environment variables on the LocalSky container:
# 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 = 30
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. Passthrough values merge at priority 30: above raw forecast data, below any direct station adapter, since they’re a routed copy of some other system’s reading. Entities reporting unavailable or unknown are skipped, not zeroed.
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:
- Shows the kind of data that would go there
- Names what additional logic the data unlocks
- Links directly to
/settings/sourceswith 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 lights up and the skip rules incorporating that field activate automatically. The engine never blocks on missing sensor data; weather + ET-based math is the always-on baseline.
Hardware compatibility matrix
| Sensor | Direct adapter | Via HA | Notes |
|---|---|---|---|
| Tempest hub (UDP) | Tested (v0.1) | Yes | Air temp, humidity, wind, solar, lightning, rain, pressure |
| Ecowitt GW1100/GW2000 LAN | Live (v0.1) | Yes | Native 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) | Yes | Polled natively via gateway; LocalSky calibrates moisture per zone against dry/wet AD endpoints in its own config; battery-powered, 868/915 MHz |
| Aqara Zigbee | Via HA | Yes | Soil moisture + temp probes; needs Zigbee coordinator |
| Sonoff Zigbee | Via HA | Yes | Same as Aqara |
| Synoptic Data | Live (v0.7.0) | N/A | A 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 Weather | Planned | Yes | Cloud API; socket.io |
| AcuRite tipping bucket | Via Ecowitt or HA | Yes | |
| PurpleAir / AirGradient | Display only | Yes | No engine integration |
| OpenSprinkler flow sensor | Native | Yes | Read via /jc water level field |
Adding a new sensor source
Same shape as adding a weather source. See CONTRIBUTING.md. 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 (the engine assumes the bucket model is correct, which it usually is)
- 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 stops guessing: the probe’s reading replaces the modeled bucket as the zone’s gate.
Supported paths in:
- Ecowitt soil probes (WH51 and friends) via a LAN gateway poll: native, no cloud, includes temperature, conductivity, and battery per probe.
- Any Home Assistant soil sensor entity, through an HA bridge source.
- MQTT topics and HTTP webhooks for DIY probes.
Assignment happens in the zone’s settings (Settings > Zones > pick the zone > soil sensor). One probe per zone; the Sensors hub shows which zones each source feeds.
How the engine uses it:
- Below the zone’s target band: the zone is eligible; runs size to the deficit as usual.
- 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.
- If a probe goes offline, or reads as a wild outlier versus its neighbours, it is flagged as an anomaly on the irrigation and zones views.
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.
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
enabledflag; 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).
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:
- Clears the probe’s binding from whatever zone used it.
- Suppresses that zone’s offline warning (a removed probe is not a fault).
- 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:
| Device | Remove from LocalSky | Also remove upstream? |
|---|---|---|
| Ecowitt gateway sensors | Yes (clears the binding) | Yes, when the gateway login is set |
| Home Assistant entities | Yes (stops consuming) | No, delete the entity in HA |
| MQTT sensors | Yes (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 stations | Yes (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
| Controller | Path | Cloud required? | Hardware cost (US$) | Status |
|---|---|---|---|---|
| OpenSprinkler (boxed) | Direct HTTP on LAN | No | 130-180 | Shipped |
| OpenSprinkler Pi | Direct HTTP on LAN | No | ~80 (Pi) + relay board | Shipped |
| DIY / ESP32 (HTTP) | Direct HTTP on LAN | No | 5-40 ESP32 + valves | Shipped |
| DIY / ESP32 (MQTT) | MQTT (ESPHome, Tasmota, Z2M) | No | 5-40 ESP32 + valves | Shipped |
| Home Assistant service call | HA REST | No (HA local) | Whatever HA drives | Shipped |
| Rachio Gen 2/3 | Rachio cloud API | Yes | 130-250 | Shipped |
| Hunter Hydrawise | Hydrawise cloud API | Yes | 130-300 | Shipped |
| Orbit B-hyve | B-hyve cloud API | Yes | 80-150 | Shipped |
| Rain Bird | Rain Bird cloud API | Yes | 100-300 | Shipped |
| DryRun | No-op | No | None | Shipped |
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.
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/controllers does this for you. The password_md5 is computed client-side at config time; the plaintext never leaves your browser.
What LocalSky uses
GET /jcfor status (zone states, water level %, rain sensor, firmware version)GET /cmfor manual station start/stopGET /cvfor stop-allGET /jlfor 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 inexamples/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.
You do not need Home Assistant for any of these; 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.)
Rachio Gen 2/3
Uses a Rachio API token. Map each zone slug to its Rachio zone UUID.
[[controllers]]
id = "rachio_main"
default = true
enabled = true
kind = "rachio"
[controllers.config]
api_token = "${RACHIO_API_TOKEN}"
device_id = "..." # Rachio device id
[controllers.config.zone_uuid_map]
back_yard = "..." # Rachio zone UUID
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).
[[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.
[[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
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.
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.
| Path | Controller kind | Board needs | You get back |
|---|---|---|---|
| HTTP / REST | http_generic | a tiny HTTP server | full status, zone discovery, wizard “test connection” |
| MQTT | mqtt_command | an MQTT client | optional 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 & path | Body | Purpose |
|---|---|---|
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. In the setup wizard, Test connection hits GET /status
and Scan zones imports GET /zones, just like OpenSprinkler.
Contract notes for firmware authors:
secondsis 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 inexamples/http/does this.run,stop, andstop_allarePOSTs. 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_tokenand 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 acontract_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/OFFon its state topic, this works out of the box (it’s what the reference firmware inexamples/esphome/uses). - Tasmota publishes plain
ON/OFFonstat/<device>/POWER, pointstate_topicthere (not the JSONtele/<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"}onzigbee2mqtt/<name>), which the whole-payload match can’t read yet. Leavestate_topicunset 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, andesphome run. - HTTP path:
examples/http/, a single ESP32 Arduino sketch implementing the five-endpoint contract above (plus optional bearer auth). Flash it, point thehttp_genericcontroller at the board’s IP, and Test connection + Scan zones work end to end. The README there includes acurlscript 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 soil-moisture bucket, and the runtime from there. Edit zones under Settings, then Zones; each save round-trips through the full config, so the engine picks up changes on the next tick.
The core fields
- Name: what you call the zone (for example “Back Yard”). It auto-derives a stable internal slug used by history and sensor bindings.
- 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 the station identifier on it. For OpenSprinkler the
station is a 1-based number (1, 2, 3); for a DIY HTTP board it is the
board’s zone id; for a Home Assistant service call it is the entity id
(for example
switch.back_yard_zone).
A zone needs a controller before it can run; configure one under Settings, then Controllers, first.
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.
- 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 engine uses its modeled soil bucket only.
- 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.
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 > Data sources and use the forecast source picker. “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, then Data sources. 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.
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. 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
| Word | Meaning |
|---|---|
| Measured | Real instrument observation from a physical station |
| Radar | Gauge-corrected radar rain estimate (observation grade) |
| Nowcast | Live radar plus station analysis (seconds of lag, grid estimate) |
| Model | Model or ML estimate of current conditions (computed, not measured) |
| Forecast | Model 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.
| Provider | Temp | Humidity | Wind | Rain rate | Rain accumulation | Pressure | Solar | UV | Lightning |
|---|---|---|---|---|---|---|---|---|---|
| Tempest (local) | Measured | Measured | Measured | Measured | Measured | Measured | Measured | Measured | Measured |
| Ecowitt (local) | Measured | Measured | Measured | Measured | Measured | Measured | Measured | Measured | Measured |
| Ambient Weather (your station, cloud) | Measured | Measured | Measured | Measured | Measured | Measured | Measured | Measured | - |
| Netatmo (your station, cloud) | Measured | Measured | Measured | Measured | Measured | Measured | - | - | - |
| La Crosse (your station, cloud) | Measured | Measured | Measured | - | Measured | - | - | - | - |
| NWS (official station) | Measured | Measured | Measured | Measured | - | Measured | - | - | - |
| NOAA MRMS (radar rain) | - | - | - | Radar | Radar | - | - | - | - |
| Synoptic Data (nearest real station) | Measured | Measured | Measured | - | - | Measured | - | - | - |
| Pirate Weather | Nowcast | Nowcast | Nowcast | Forecast | - | Nowcast | - | Nowcast | - |
| OpenWeather | Model | Model | Model | Forecast | - | Model | - | Model | - |
| Apple WeatherKit | Model | Model | Model | Forecast | - | Model | - | Model | - |
| Open-Meteo | Model | Model | Model | Forecast | Forecast | Model | Model | Model | - |
| Met.no | Model | Model | Model | Forecast | - | 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.
| Provider | Key | Liveness | Refreshes | Good up to | Locality |
|---|---|---|---|---|---|
| Tempest (local) | none | Live LAN station | 60s | 600s (10min) | Your exact yard |
| Ecowitt (local) | none | Live LAN station | 30s (gw samples ~16s) | 600s (10min) | Your exact yard |
| Davis (local) | none | Live LAN station | 10s | 600s (10min) | Your exact yard |
| Ambient Weather (your station, cloud) | free key | Real station via cloud | 60s | 3900s (65min) | Your exact yard |
| Netatmo (your station, cloud) | free key | Real station via cloud | 10min | 3900s (65min) | Your exact yard |
| La Crosse (your station, cloud) | free key | Real station via cloud | 5min | 3900s (65min) | Your exact yard |
| NWS (official station) | none | Official observation, lags 30-90min | 30min | 2100s (35min) | Nearest station, often an airport 5-30 miles away |
| NOAA MRMS (radar rain) | none | Radar QPE (observation grade) | 3min | Rate 900s (15min), accum 7200s (2hr) | 1 km radar grid over your block |
| Synoptic Data (nearest real station) | free key | Real station observation | 10min | 3900s (65min) | Nearest station, can be several miles away |
| Pirate Weather | free key | Split: live nowcast + model rain forecast | 10min | 3900s (65min) | ~3 km grid in the US |
| OpenWeather | paid | Model forecast | 10min | 3900s (65min) | ~500 m to 2 km cell |
| Apple WeatherKit | paid | Model forecast | 10min | 3900s (65min) | Tuned to your coordinates (most precise cloud) |
| Open-Meteo | none | Model forecast | 1-6 hr upstream | 2100s (35min) | ~2 to 13 km model grid |
| Met.no | none | Model forecast, no live rain reading | 30min | 2100s (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.
| Provider | Kind | Coverage | Good for |
|---|---|---|---|
| LibreWXR | Radar + nowcast | US, Canada, Europe, Japan, Taiwan, SE Asia | The regional default where covered: real radar plus a 60-minute nowcast |
| RainViewer | Radar | Global | The worldwide fallback: animated precipitation anywhere on Earth |
| IEM NEXRAD | Reflectivity (WMS) | US (CONUS) | Sharp, street-scale US base reflectivity |
| NOAA MRMS | Radar rain | US (CONUS) | Observation-grade gauge-corrected radar rainfall; the best off-yard read of the rain that actually fell on your location |
| NOAA nowCOAST | Reflectivity (WMS) | US incl. Alaska, Hawaii, Caribbean, Guam | US detail beyond the contiguous 48 |
| Environment Canada GeoMet | Reflectivity (WMS) | Canada | National 1 km precip-rate composite |
| DWD | Reflectivity (WMS) | Germany / Central Europe | RADOLAN precipitation composite |
| FMI | Reflectivity (WMS) | Finland | National 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
- Forecast sources and merge: how the forecast numbers the precip layer draws are produced.
- Weather and soil sensors: the local station behind the lightning layer.
- Configuration reference: the
ui.radarconfig block field by field.
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:
| Verdict | Meaning |
|---|---|
| Run | Conditions clear every skip rule; zones water their planned minutes. |
| Skip | A rule trips (rain, wind, cold, soil already wet); the reason is on the card. |
| Extend | A heat trigger lengthens runs beyond the baseline plan. |
| Off | Watering 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.
| Rule | Default | What it protects |
|---|---|---|
| Rain in the recent window | 0.20 in (5 mm) | Don’t water what the sky watered. |
| Observed rain recently | ~0.25 in over today plus the past day | Measured rain skips on its own, independent of any soil reading. |
| Rain expected in the next hours | forecast x probability | Don’t water ahead of a storm. |
| Wind | 10 mph (16 km/h) | Spray pattern integrity (drift loss). |
| Freeze / low temperature | 38 F (3.3 C) | Ice on hardscape, plant shock. |
| Soil moisture (per zone, with a probe) | zone target band | The probe outranks the model. |
| Allowed days / restrictions | local rules | Water-authority schedules, municipal restrictions, HOA rules. |
| Vacation pause / dry-run | manual | You 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.
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 neighbours (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.
Why this duration?
Every zone’s detail view includes the full arithmetic behind tonight’s planned minutes, because “trust me” is not a number.
The chain, top to bottom:
- Bucket deficit (mm): how far the zone’s modeled soil moisture sits below full. Rain and runs fill it; daily crop ET drains it.
- 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.
- 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.
- Throughput (mm/hr): how fast your sprinklers actually apply water, either measured (catch cups) or the catalog default for the head type.
- Capture efficiency: how much of the applied water lands in the root zone (wind drift, overspray, runoff losses).
Planned seconds = deficit / (throughput x efficiency), capped by the zone’s max-runtime guard. Every input is shown live with its source, so when a number looks wrong you can see exactly which knob to turn.
Weekly water budget
The budget panel 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.
Counted in:
- Irrigation runs (recorded per zone, per second of runtime, converted through the zone’s precipitation rate).
- Measured rainfall (from your station or gateway).
The target comes from the zone’s crop evapotranspiration: daily ET0 (computed FAO-56 from your weather) times the species coefficient for the season, summed over the week. A warm-season lawn at the height of summer needs the full bucket; the same lawn in midwinter needs a fraction (the engine flips seasons automatically south of the equator).
Reading the bars: a zone sitting near 100% is on plan. Persistently under target means runs are being skipped or are too short (check the zone’s math panel); persistently over means rain is doing the work and the engine should be skipping more, or the precip rate is set too low.
The budget is advisory; it never blocks watering by itself. The deficit model (soil bucket) is what gates actual run decisions.
History
Every run, every skip, every decision, kept locally and rendered as a story instead of a log.
- Watered minutes per day across the selected window (30/90/365 days), with the per-zone split below.
- Watering calendar: one square per day; greener = more water, empty = a skip day.
- Why it skipped: the engine’s decisions aggregated by reason (rain, wind, restriction, cold, soil), so a season of judgment is one glance.
- Per-zone rows with sparklines, for spotting a zone that’s drifting from its siblings.
The Print button turns the page into a clean seasonal report. Data lives in LocalSky’s own SQLite store; nothing depends on a cloud service or another system’s recorder.
Notifications
LocalSky can push three classes of events to your subscribed devices:
- Zone started when an irrigation zone transitions from idle to running.
- Zone stopped when a zone finishes, with the duration in minutes.
- Daily verdict once per day, the first time the skip-check verdict is computed (skip / run / run extended, with the reason).
Web Push (browser / PWA) is the delivery channel implemented today. The configuration schema and the Settings UI also carry blocks for MQTT, ntfy, Slack, and email; those sinks are scaffolded but event delivery for them is not wired up in this release. (LocalSky’s MQTT support today publishes Home Assistant discovery entities and sensor states, which is a separate feature: see the HACS integration page.)
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:
| Variable | What it is |
|---|---|
VAPID_PRIVATE_KEY_PATH | Path (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_KEY | The 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_SUBJECT | Optional 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-pushNode CLI:npx web-push generate-vapid-keysemits the private key as a raw base64url scalar, not a PEM file. That string cannot be dropped intovapid-private.pemas-is (and wrapping it inBEGIN PRIVATE KEYmarkers does not make it PKCS#8). Use theopensslflow 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 on 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-keydistinguishes the two:503means keys, and503fromPOST /api/v1/push/subscribewith"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 forpush: send ... failedlines.
What fires when
| Event | Trigger |
|---|---|
| Zone started | A zone’s running state flips from off to on |
| Zone stopped | A zone’s running state flips from on to off (carries the run duration in minutes) |
| Daily verdict | The 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 bucket 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. 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. 13Rn– net radiation (MJ/m²/day), eq. 38 + 39 + 40G– soil heat flux (~0 for daily timescale over grass)γ– psychrometric constant (kPa/°C), eq. 8 = 0.665e-3 × PT– mean daily temperature (°C)u₂– wind at 2m (m/s)es– saturation vapor pressure (kPa), eq. 11 + 12ea– 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. Hand-trace tested against eq. 6 for a 50°N April day (Tmax 21.5, Tmin 12.3, RH 84/63, u₂ 2.78, Rs 22.07): ~3.51 mm/day.
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, different et0_method label for operators who want their dashboards to read “ASCE” instead.
3. Hargreaves-Samani 1985
Fallback when wind, solar, or humidity are missing:
ET₀ = 0.0023 * (Ra * 0.408) * (Tmean + 17.8) * sqrt(Tmax - Tmin)
Typical bias vs. PM is +/- 15-25% depending on climate; humid and windy climates see the largest errors. Acceptable when better data isn’t available; LocalSky flags Hargreaves-derived values in the dashboard math tile so the operator knows.
Crop ET (ETc)
For each zone:
ETc = ET₀ * Kc(species, DOY) * heat_multiplier(heat_index)
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.
heat_multiplier is the NOAA Steadman heat index applied as an ET boost from 1.00 at HI <= 85°F up to 1.30 at HI >= 105°F. Captures the empirical observation that 100°F + 70% RH dries a lawn faster than ET₀ alone predicts. Defined in src/engine/skip_rules.rs.
The heat index is computed per day: each day’s high temperature is paired with that same day’s humidity (the humidity at the time they co-occur), not the current “now” humidity. Pairing a cool, damp morning reading with the afternoon peak would inflate the multiplier, so the engine keeps the co-occurring pair intact.
Soil water balance
Per zone, LocalSky tracks one number: depletion_mm, the millimetres of water below field capacity. State evolves daily:
depletion[t+1] = clamp(depletion[t] + ETc - effective_rain - applied_water,
0, TAW)
Where:
effective_rain = gross_rain * capture_efficiency. Default capture efficiency is 0.70 (operator-tunable); accounts for runoff + canopy interception + evaporation losses before water enters the root zone.applied_wateris the depth (mm) of irrigation that reached the soil during this tick.TAW(Total Available Water, mm) =(FC - WP) * root_depth_mm. FC and WP come from the soil texture catalog (USDA classes, sourced from FAO-56 Table 19 and USDA NRCS Part 652).
Trigger to irrigate:
needs_irrigation = (depletion >= RAW)
RAW = TAW * MAD%
MAD (Management Allowed Depletion) defaults per species. St. Augustine: 50%. Bahia: 55%. Ornamental shrubs: 40%. The catalog cites UF/IFAS extension publications for the warm-season species and FAO-56 Table 12 for the cool-season and non-turf categories.
Implementation: src/engine/water_balance.rs.
Runtime to depth
Once the engine decides to irrigate, runtime in seconds is:
gross_mm_needed = depletion_mm / capture_efficiency
seconds = (gross_mm_needed / precip_rate_mm_hr) * 3600
precip_rate_mm_hr per zone comes from either a measured catch-cup calibration (preferred) or the sprinkler-type default (rotor ~10 mm/hr; spray ~38 mm/hr; MP rotator ~10 mm/hr; drip ~4 mm/hr).
Runtime is capped at max_duration_s so a misconfigured precip rate can’t run a zone for hours.
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
insert soak_minutes (default 30) between cycles
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.
Worked example: clay (5 mm/hr infiltration on flat), spray head (15 mm/hr precip), 45-minute total runtime -> 3 cycles of 15 min with two 30-min soaks. Total elapsed wall-clock: 1h 45min. Total water applied: same 45 minutes worth, but it actually enters the root zone instead of running off.
Implementation: src/engine/cycle_soak.rs.
Skip rules
Before any zone fires, the engine runs a deterministic rule ladder. First matching rule wins. Order encodes intent: explicit user overrides > paused > current-conditions safety (raining now, freeze, soil frost, wind) > observed recent rain > soil saturation > forecast skips > heat advisory > dry-run > 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 neighbouring probes (soil quarantine) so one bad reading can’t force a skip or a needless run.
Full enumeration in skip-rules.md. All thresholds are typed config fields in cfg.engine.skip_rules; defaults match the original hardcoded values exactly so upgrading doesn’t change any verdict for unchanged inputs.
Heat advisory pre-water
When the 3-day forecast shows >= 95°F + >= 60% RH and the zone has been dry for >= 2 days, the engine returns verdict run_extended instead of plain run. Dashboard surfaces this; the controller adapter receives 115% of the computed runtime. Empirically gets ahead of the heat stress before it shows in the soil moisture data. Disabled if the 3-day rain forecast covers >= half the operator’s rain-skip threshold.
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:
| column | source |
|---|---|
predicted_in | The morning’s forecast (forecast.daily[0].precipitation_sum). First write of the day wins. |
observed_in | The day’s end-of-period observed rain from the merged snapshot. Updated as the day accumulates. |
month | 1..12, denormalized so the bias query indexes by month-of-year. |
The first write of the day plants the prediction; the rest of the day refines the observation. Once MIN_OBSERVATIONS (currently 5) 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/biasreturns 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. - Skip rules: v0.1 surfaces the model and persists the observations but does not yet multiply the rain inputs going into the skip ladder. A v0.2 release will wire
corrected_rain = raw_rain * multiplierupstream ofskip_rules::evaluateso the morning verdict reflects the learned bias automatically.
Defaults and bounds
| Constant | Value | Why |
|---|---|---|
MIN_OBSERVATIONS | 5 | Below this, a single outlier dominates. Multiplier stays at 1.0. |
BIAS_FLOOR | 0.5 | Real bias rarely halves a forecast; below this is almost certainly a broken pipeline. |
BIAS_CEIL | 1.5 | Same intuition on the other side. |
DEFAULT_WINDOW_DAYS | 90 | One season. Tracks microclimate shifts without dragging in last year’s summer into this year’s. |
NOISE_FLOOR_IN | 0.02 | Below 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).
Where to read further
- Grass species catalog: 12 species with monthly Kc curves and citations
- Soil texture catalog: USDA classes with FC, WP, AW, infiltration
- Skip rules: every rule in the ladder with its config knob
- Configuration reference: every
cfg.engine.*field and its default
Skip Rules
LocalSky’s irrigation skip-check is a 17-rule ladder. Every morning (or whenever the engine recomputes), inputs flow through the ladder in order. First matching rule wins. Order matters: explicit overrides beat safety beats current conditions beat forecast beats heat advisory beats dry-run beats run.
Source: src/engine/skip_rules.rs.
Ladder
| # | Rule | Trigger | Threshold | Tunable? |
|---|---|---|---|---|
| 1 | Manual override: skip tomorrow | is_tomorrow && override_tomorrow == "skip" | none | UI |
| 2 | Manual override: run tomorrow | is_tomorrow && override_tomorrow == "run" | none | UI |
| 3 | Vacation pause (timed) | pause_until_epoch > now_epoch | none | UI |
| 4 | Vacation pause (toggle) | is_paused == true | none | UI |
| 5 | Currently raining | rain_intensity_now_in_hr > 0.01 | 0.01 in/hr (0.25 mm/hr) | rain_now_in_hr |
| 6 | Freeze risk now | temp_now_f < min_temp_f | 38°F (3.3°C) | min_temp_f |
| 7 | Overnight freeze | temp_min_24h_f < min_temp_f | 38°F (3.3°C) | min_temp_f |
| 8 | Soil frost | soil_temp_yard_min_f < frost_skip_soil_f | 35°F (1.7°C) | frost_skip_soil_f |
| 9 | Wind too high now | wind_now_mph > max_wind_mph | 10 mph (16 km/h) | max_wind_mph |
| 10 | Windy day forecast | wind_max_today_mph > max_wind_mph + 5 | +5 mph (8 km/h) slack | wind_forecast_slack_mph |
| 11 | Already wet | rain_today_in >= 0.05 | 0.05 in (1.3 mm) | already_wet_in |
| 11b | Observed rain recently | rain_observed_recent_in >= rain_skip_in | 0.25 in (6.4 mm) over the recent window | rain_skip_in, rain_observed_window_days |
| 12 | All zones soil-saturated | every zone’s moisture % >= saturation threshold | per-zone | per-zone soil settings |
| 13 | Rain in next 4 hours | rain_next_4h_in >= 0.10 | 0.10 in (2.5 mm) | rain_next_4h_skip_in |
| 14 | Tomorrow rain (confidence-weighted) | forecast_in * prob/100 >= rain_skip_in | 0.25 in (6.4 mm), weighted | rain_skip_in |
| 15 | 3-day rain rollup | rain_3day_weighted_in >= 1.5 * rain_skip_in | 1.5x multiplier | rain_3day_factor |
| 16 | Heat advisory (pre-water) | 3-day max >= 95°F (35°C) + humidity >= 60% + 2+ dry days | composite | heat_advisory_* |
| 17 | Dry-run mode | is_dry_run == true | none | UI |
| - | Default | (no rule matched) | none | run |
Verdict types
The ladder returns one of three verdicts:
skip: don’t irrigate.reasoncarries a human-readable explanation.run: proceed with the engine’s computed runtime.run_extended: proceed at 115% of the engine’s computed runtime. Used only by rule 16 (heat advisory pre-water).
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.”
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)
Skip only when EVERY zone reports moisture >= its per-zone saturation threshold AND every zone has a current reading (no None / probe-offline). A single dry zone or a single missing reading breaks the skip. The per-zone HA automation irrigation_per_zone_saturation_skip still mutes individual saturated zones; this rule operates at the sequence level.
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).
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 honours 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 rule that can fire run_extended. 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)
Empirically gets ahead of heat stress that ET-based math underestimates on multi-day spikes. Disabled in cooler climates by raising heat_advisory_temp_f.
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 engine matches the list against the parity you set here.
This setting matters even if you only ever use one restriction. When parity is N/A, the engine treats odd/even weekday rules as “no weekday gate” and silently ignores them, so a day-of-week restriction will not block anything. The page warns you loudly if you have an enabled restriction with weekday rules but parity is still N/A. 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 built from four gates. Any gate you leave blank is simply inactive.
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 (odd and even addresses)
The days you are allowed to water, given as two lists: one for odd addresses, one for even. The engine reads the list that matches your address parity. An empty list means no weekday restriction (water any day). If today is not on your list, the run skips with “today is not an allowed watering day”.
Use this for “two days a week” rules and for odd/even rotation schedules. For a flat “everyone waters the same two days” rule, set the same days in both the odd and the even list.
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.
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 click Save all changes to persist. Adding the same template again replaces it rather than duplicating it.
Saving
The page edits a working copy; nothing is enforced until you click Save all changes at the bottom. That persists the restrictions and your address parity together, and the engine picks them up on its next tick. To remove a restriction, use Delete on its card, then save.
Where to read more
- Skip rules at a glance: the full veto ladder, including where restrictions sit.
- Skip rules in depth: how each input becomes a verdict.
- Irrigation engine: the scheduling and duration math a cap is applied against.
Manual schedules
Most of the time you want LocalSky’s smart engine to decide when and how long to water: it reads the weather, runs the soil-water-balance math, and fires a zone the moment its deficit justifies it. 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 smart math still computes and still shows up in nerd-mode and on the zone-math tiles, so you can see what the engine would have done, but it does not dispatch. The manual schedule is the only thing that fires.
Use Override when you want full manual control of a zone: the clock you set is exactly what runs, no more, no less (restrictions aside, see below).
Floor
In Floor mode the manual schedule is a minimum, not a replacement. The manual run fires on schedule, and the smart engine is still free to add more runs for that zone if its deficit math 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. The trade-off is that Floor can overwater if your scheduled run already satisfies the deficit, because the engine does not subtract the manual run from its own sizing. Reach for Override unless you specifically want the engine to keep adding water.
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.
Restrictions still apply
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
The page edits a working copy. Add or edit a schedule with the form, then click Save all changes at the bottom to persist. Saving round-trips through the config API. The dispatcher reads the schedule list at startup, so a newly added or edited schedule takes effect on the next container restart rather than mid-run. Restrictions and the smart engine pick up changes on their own next tick, but the manual-schedule clock is read once at boot.
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:
- 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. - 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. - 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
Values per FAO-56 Table 19 + USDA NRCS Part 652 Table 11-3.
| Texture | FC (m³/m³) | WP (m³/m³) | AW (mm/m) | Infil flat (mm/hr) | Infil 3-5% (mm/hr) | Infil >5% (mm/hr) |
|---|---|---|---|---|---|---|
| Sand | 0.09 | 0.03 | 60 | 50 | 35 | 25 |
| Loamy sand | 0.14 | 0.06 | 80 | 35 | 25 | 18 |
| Sandy loam | 0.23 | 0.10 | 130 | 25 | 18 | 12 |
| Loam | 0.34 | 0.12 | 220 | 13 | 10 | 7 |
| Silt loam | 0.32 | 0.15 | 170 | 10 | 8 | 5 |
| Clay loam | 0.39 | 0.20 | 190 | 8 | 6 | 4 |
| Clay | 0.42 | 0.25 | 170 | 5 | 4 | 3 |
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.34 - 0.12) * 250 = 55 mm, nearly triple 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 threshold beyond which the plant starts to stress. LocalSky’s irrigation trigger is depletion >= RAW.
St. Augustine on sandy loam with default 50% MAD: RAW = 19.5 * 0.50 = 9.75 mm. The engine triggers irrigation when the bucket dips below ~10 mm of depletion.
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: spray head (15 mm/hr precip) on clay flat (5 mm/hr infiltration). Each minute of runtime delivers 15/60 = 0.25 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. LocalSky’s default minimum cycle is 3 minutes; soak gap is 30 minutes; the splitter computes the maximum continuous on-time at ~(infiltration/precip) * 60 minutes.
Picking the right texture for your zone
Without a soil test, two practical methods:
Ribbon test
- Take a handful of moist (not wet) soil. Squeeze into a ball.
- Squeeze the ball through your thumb and forefinger to form a ribbon.
- 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
- Half-fill a one-litre (quart) jar with soil from the zone’s root depth.
- Fill the rest with water + a teaspoon of dish soap.
- Shake hard. Set aside.
- After 1 minute, mark the sand layer (settles first).
- After 2 hours, mark the silt layer.
- After 24-48 hours, mark the clay layer (or what hasn’t settled yet).
- 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
- USDA NRCS National Soil Survey Handbook
- FAO Irrigation and Drainage Paper No. 56, Chapter 8 (ETc - Single Crop Coefficient)
- USDA NRCS Part 652 National Irrigation Guide, Chapter 11 (Sprinkler Irrigation)
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"]
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 believeX-Forwarded-For. See below.
X-Forwarded-For, trusted_proxies, and trusted_networks
How LocalSky determines the client address, exactly:
- The TCP peer address of the connection is authoritative. That is the address LocalSky uses by default.
X-Forwarded-Foris only believed when the peer itself is one of yourtrusted_proxies. When it is, LocalSky walks the header from the right, skips any hops that are also intrusted_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.)- If the peer is not in
trusted_proxies,X-Forwarded-Foris ignored entirely and the peer address wins. A client that reaches the LocalSky port directly therefore cannot spoof its address by sending its ownX-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_networksmatches the proxy, not the real client. If the proxy’s address falls inside atrusted_networksCIDR, 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.
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_networksset andtrusted_proxiesempty/wrong. Withtrusted_proxiesempty 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 atrusted_networksrange directly. - Do not list a CIDR in
trusted_proxiesthat untrusted clients can originate from.trusted_proxiesis “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_proxiesempty. The TCP peer address is used and there is nothing to forge below L3;trusted_networksis 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_proxiesto 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.)
What stays public
These paths never require credentials, by design:
| Path | Why |
|---|---|
/pkg/*, /sw.js, root static assets | Compiled assets; browsers fetch them without credentials |
/api/v1/info | Pairing 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/health | Liveness for Docker healthchecks; anonymous callers get a trimmed body (no source, controller, or HA detail) |
/metrics | Prometheus 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 APIs | Only 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_...:
- In LocalSky: Settings, then Account, then Create token (name it, e.g.
home-assistant). - The plaintext is shown exactly once; store it where the integration asks for it. Only a hash is kept server-side.
- 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
/loginfirst (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:
- Pass
X-Forwarded-Proto: httpsso LocalSky marks its session cookieSecure. - Overwrite (never append to)
X-Forwarded-Forwith the real client address. LocalSky reads the first hop of that header forauth.trusted_networksand 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. - 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
/setupand/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
/metricsfrom 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/metricsat the proxy (let your monitoring host reach it directly). - Keep
/pkg/*and/sw.jsreachable 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 routinedocker compose pullcan 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
- Database migrations run. LocalSky keeps a chain of numbered SQLite migrations (M0001 through M0012 as of this release) and records each applied one in a
schema_migrationstable. 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. - The config file loads.
/data/localsky.tomlcarries aschema_versionfield (currently1). 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. - 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/PGIDunset or set them to10001so nothing re-chowns. - If you carried a stale
PUID=1000from an old.env, the container will now run as 1000 and re-chown/datato 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_versionabove what the running binary supports, the loader refuses it withrefusing to load a config newer than this binaryand LocalSky boots as if unconfigured rather than guessing. Restore the pre-upgradelocalsky.tomlfrom your backup (or re-upgrade). As of this releaseschema_versionis still1, 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.dbthat predates the migration runner is detected on first boot. The legacyrunstable 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 unchanged:schema_version = 1then isschema_version = 1now.- 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
| File | What it holds |
|---|---|
localsky.toml | Your entire configuration: location, sources, controllers, zones, schedules, restrictions, notification channels |
irrigation.db | The 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-shm | SQLite write-ahead-log sidecars; present while the container runs |
localsky.toml.draft | First-run wizard progress, if you saved mid-wizard; deleted when the wizard finishes |
instance-id | A 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 even an unclean shutdown rolls back to a consistent state on the next boot.
Built-in backup (recommended)
LocalSky can produce a consistent backup bundle while running: a .tar.gz containing localsky.toml, 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.tomlis 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.gzis 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_PATHpoints). 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
# 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.
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:
- The config is validated first (a broken file is rejected with a 422 and changes nothing), then applied immediately.
- The database is not swapped live. It is staged next to the real one as
irrigation.db.restore; on the next container start, LocalSky moves the current database aside (kept asirrigation.db.pre-restore.<timestamp>, so a restore is reversible) and swaps the staged one in. That is why the response says"restart_required": truewhenever a database was uploaded.
You can also restore the pieces individually: -F [email protected] applies just a config (no restart needed), -F [email protected] stages just a database.
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
cp /backup/localsky/localsky-2026-06-01.toml /opt/localsky/data/localsky.toml
docker start localsky
Remove the -wal/-shm sidecars when replacing the database file; stale ones belong to the old database. Restoring a database from an older release is fine: boot replays whatever schema migrations it is missing.
Test your restore
A backup you have never restored is a hope, not a backup. Five minutes proves yours works, without touching production:
mkdir -p /tmp/localsky-restore-test
docker run -d --name localsky-test \
-p 8091:8090 \
-v /tmp/localsky-restore-test:/data \
-e LOCALSKY_DEMO=1 \
ghcr.io/silenthooligan/localsky:latest
# Push the bundle into the test instance, then restart to swap the DB in:
curl -f -X POST -F [email protected] \
http://localhost:8091/api/v1/backup/restore
docker restart localsky-test
Open http://localhost:8091 and check that your zones, settings, and run history are all there. LOCALSKY_DEMO=1 keeps the test instance’s live data paths switched off, so it will not poll your weather sources, and weather shown is synthetic; it exists only to prove the bundle restores. Even so, the restored config names your real irrigation controller, so do not press run buttons on the test instance. Tear it down when satisfied:
docker rm -f localsky-test && rm -rf /tmp/localsky-restore-test
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
A note on config snapshots: the database has a snapshot table and a POST /api/v1/config/rollback?to=<version> endpoint (snapshots listed at GET /api/v1/backup/snapshots), but in this beta saves do not record snapshots yet, so the list stays empty and rollback returns 404. Until that lands, your backup bundles are the config history.
“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.
Related pages
- Upgrading LocalSky: always back up before an upgrade; restoring is the supported downgrade path
- Configuration reference: every field in
localsky.toml - Authentication: creating the
lsk_API tokens used in the curl examples
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 surfaces the raw engine math everywhere. With it on, every irrigation panel shows the numbers behind the verdict instead of just the conclusion: reference evapotranspiration (ET0), crop evapotranspiration (ETc), soil bucket depth, the species crop coefficient (Kc), the management allowed depletion (MAD), available water, and root depth.
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 or features.demo_mode = true in /data/localsky.toml. 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
- Backup, restore, and recovery: the full backup workflow and what each bundle contains.
- Configuration reference: every field the raw editor exposes.
- Upgrading LocalSky: version upgrades and the update check.
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:
statusis 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 withlast_seen_epoch,stale_for_s, and astatusoffresh,stale, oroffline. 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(standaloneorhome_assistant),mqtt_discovery(outbound MQTT publishing on),hacs_last_seen_epochandhacs_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 instead if you want your monitor to alert on degraded, not just on dead.
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:
/datais mounted read-only. The container cannot fix ownership of, or write to, a read-only mount. Mount/dataread-write./datais a NAS / NFS share the container can’t chown (Synology, QNAP). Exports withroot_squashor “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/dataisn’t writable as 10001, it runs as the volume’s actual owner instead and logsrunning as its owner <uid>:<gid>. If you’d rather pin it, setPUID/PGIDto the uid:gid that owns the share (find it in Synology File Station, or runidon the NAS):environment: - PUID=1026 # the share's owning uid - PGID=100 # the share's owning gid- You overrode the entrypoint (a custom
entrypoint:, oruser:set to a uid that can’t write the volume). PreferPUID/PGIDoveruser: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 -mshould reportaarch64. 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:
- Host networking (same broadcast limitation as Tempest above).
- 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/healthflips todegraded.- 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 (smart morning: controller dispatch failed), abandons the rest of that zone’s segments, and moves on to the next zone. 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).
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 honest.
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
controllersblock in/api/v1/health: is the controller enabled, and is one markeddefault? - 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
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.
Lots of skips in the first week
Expected. Each zone’s soil bucket starts full (zero depletion, soil assumed at field capacity). The engine will not water until evapotranspiration draws the bucket down past the allowed depletion for your soil and species, which typically takes days. If you know the soil is actually dry on day one, run the zones manually once; the engine accounts for the applied water and the model converges from there.
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 via HACS as a custom repository; 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.
- 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, a full soil bucket, restriction calendars, and so on. The UI shows the exact threshold that tripped. 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, no per-install identifier sent anywhere. The only optional phone-home is the update check above, off by default. When you enable it, the daily request to localsky.io carries the running version in its User-Agent, and (as with any web request) the server can see your IP; the maintainer reads those access logs only as aggregate version counts. 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), the number that drains the soil bucket 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: the per-zone water-balance model; rain and irrigation fill it, ETc drains it, and “depletion” is how far below full it currently sits.
- 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._tcpon 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 = 1
[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 degreeslocation.elevation_m: optional, used by FAO-56 net-radiationunits:"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 heretimezone: optional IANA name. Null derives from lat/lon at bootdisplay_name: surfaces in the MQTT discovery node_id (slugified) and the dashboard title
[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 swaps every controller for DryRun and uses the synthetic DemoReplay source.
[[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:
mqttsubscribes to broker topics (Tasmota, ESPHome, Zigbee2MQTT, any raw publisher). Config:broker_host,broker_port(default 1883), optionalusername/password, and asubscriptionslist mapping each topic to a weather field with optional scale/offset.http_webhookaccepts 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-secrettoken(sent as theX-LocalSky-Tokenheader or?token=query parameter), and afieldsmapping 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)”.
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
controller_id = "os_main"
controller_station = "1" # 1-based for OS; entity_id for HA / ESPHome
soil_sensor_id = null # optional; engine uses modeled bucket when absent
target_min_pct_soil = 30.0
saturation_pct_soil = 70.0
photo_url = null
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.
[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/..."
[notifications.email]
smtp_host = "smtp.example.com"
smtp_port = 587
username = "..."
password = "..."
from_address = "[email protected]"
to_address = "[email protected]"
starttls = true
Each section is optional. Omit to disable that channel.
[engine]
[engine]
capture_efficiency = 0.70
session_rain_defer_in = 0.10
soak_minutes = 30
et0_method = "auto" # auto | penman_monteith | asce_simplified | hargreaves_samani | source_native
[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. See skip-rules.md for what each one does.
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
override(default): while an enabled override schedule applies to a zone that day, smart-irrigation dispatch for that zone is suppressed. The smart math still computes for visibility.floor: the schedule fires AND the smart engine may add more water if its deficit math justifies it. Useful for minimum-coverage requirements; can overwater if the scheduled run already covers the deficit.
Manual schedules respect watering restrictions exactly like smart runs do: a blocked dispatch is skipped with the reason logged to run history.
[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"]
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.
[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 rawsensor_historyreadings to keep. Rows older than this are pruned opportunistically as new readings arrive.0disables 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 (seeradar_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_versionmust 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_idmust reference a configured controller latin[-90, 90],lonin[-180, 180]
Bad PUTs return 422 with the specific failure; on-disk file is untouched.
Migrations
On boot, the 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 1; 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":1,"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/*; the bare /api/* paths exist for backwards compatibility with v0.1 and will be removed in a future major release. A few newer endpoint families (/api/v1/backup, /api/v1/updates) exist only under /api/v1.
On this page
- Versioning
- Authentication
- Snapshot endpoints
- Configuration endpoints
- Wizard endpoints
- Irrigation control endpoints
- Devices
- Sensors and weather history
- Radar map data
- Web Push endpoints
- Zone photos
- Ingest endpoints
- Health and meta
- Backup and restore
- Service worker and PWA
- Client tooling
Versioning
The /api/v1 namespace is the stable contract. Version semantics:
- major (
v1->v2): breaking change to any response shape or required field. Both versions ship in parallel during the deprecation window. - 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.
GET /api/v1/info
Returns the running service version, the API contract version, and the mount prefix. Hit it first when probing a LocalSky instance. Always public, even when authentication is required.
{
"service": "localsky",
"service_version": "0.7.0",
"api_version": "1.15.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_requiredtells 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.uuidis 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_runanddemoflag instances running withLOCALSKY_SMART_DRY_RUN=1orLOCALSKY_DEMO=1.has_irrigationis 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_defaultis the server-configuredfeatures.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:
Authorization: Bearer lsk_...: a long-lived API token created under Settings, then Account. This is what integrations (HACS, scripts, dashboards) should use.?access_token=lsk_...: the same API token as a query parameter, accepted only on paths ending in/stream(browserEventSourcecannot set headers). It is ignored everywhere else.- Session cookie:
localsky_session=lss_..., set byPOST /api/v1/auth/login.HttpOnly,SameSite=Lax, markedSecurewhen the request arrived over HTTPS (detected viaX-Forwarded-Proto). Lifetime issession_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:
| Path | Why it is public |
|---|---|
/pkg/*, /sw.js | Compiled 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/info | Pairing 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/health | Always reachable for Docker healthchecks, but anonymous callers get a trimmed liveness-only body (no source, controller, or HA detail) |
/metrics | Prometheus 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
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/auth/status | GET | { mode, setup_complete, authenticated }; always public |
/api/v1/auth/setup | POST | Create the first owner account {username, password}; 409 once one exists |
/api/v1/auth/login | POST | Sign in {username, password}; sets the session cookie |
/api/v1/auth/logout | POST | Clear the session |
/api/v1/auth/session | GET | Current user (401 when anonymous and auth is required) |
/api/v1/auth/tokens | GET / POST | List / create API tokens ({name} -> {token}, shown exactly once) |
/api/v1/auth/tokens/{id} | DELETE | Revoke 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": 0.0,
"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
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.
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/wizard/draft | GET / PUT / DELETE | Read, save, or discard the wizard draft |
/api/v1/wizard/apply | POST | Validate the draft and write it as the live config |
/api/v1/wizard/state | GET | Wizard progress state |
/api/v1/wizard/seed_current | POST | Seed the draft from the current live config (re-running the wizard) |
/api/v1/wizard/test_source | POST | { "source": <SourceEntry> }; structural validation of the entry. No live probe per kind yet: receiver sources confirm via live readings on the Sensors hub, polled sources within one cycle after apply |
/api/v1/wizard/test_controller | POST | { "controller": <ControllerEntry> }; live connect + status read. Returns { ok, reachable, master_enabled, water_level_pct, zone_count, firmware }, 502 if unreachable, 422 if unsupported |
/api/v1/wizard/test_llm | POST | { "llm": <LlmConfig> }; live probe of the configured LLM provider |
/api/v1/wizard/scan_zones | POST | { "controller": <ControllerEntry> }; zone discovery for controllers that support it, pre-populates the zone editor |
/api/v1/wizard/probe_soil | POST | { "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/discover | GET | One LAN sweep: passive Tempest, Ecowitt broadcast, OpenSprinkler probe |
/api/v1/wizard/geocode?q=<address> | GET | Server-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:
runis clamped server-side to 7200 seconds (2 hours) regardless of what the client sends.set_thresholdaccepts only the known keysmax_wind_mph,min_temp_f,rain_skip_in.set_override_tomorrowtakes"none" | "skip" | "run"(a one-day, HA-helper override).set_pause_untilwithepoch: 0clears the vacation pause (same asclear_pause_until).set_global_overridetakes"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_overridetakes azoneslug 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 always handled by LocalSky’s own state (a small SQLite store) whenever a persistence DB is mounted, so they work on both standalone and Home-Assistant-sourced deployments.
run_sequence_nowwas removed along with Irrigation Unlimited support. The action still deserializes so an old client gets a clear410 Gone({"error": "run_sequence_now was removed along with Irrigation Unlimited support; use per-zone Run instead"}) rather than a parse error. Use a per-zoneruninstead.
GET /api/v1/irrigation/history?days=30
Run history window, counted backward from now. days defaults to 30 and clamps to 1..365.
{
"from_epoch": 1762808000,
"to_epoch": 1765400000,
"runs": [
{ "zone": "back_yard", "start_epoch": 1765320000, "duration_s": 600, "skip_reason": null }
]
}
Rows with a non-null skip_reason are skip events rather than completed runs.
GET /api/v1/irrigation/decisions?days=30
Verdict-transition history: one record per change of the skip-check verdict, so you can answer “did we actually skip on day X, and why” weeks later. Same days parameter semantics as /history.
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
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.
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
Shadow mode: the native (standalone) snapshot built alongside the Home Assistant one for comparison. Empty unless shadow_native is enabled.
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).
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/sensors/soil | GET | Soil-moisture channels for the zone picker |
/api/v1/sensors/discovered | GET | Every relevant entity LocalSky can see, grouped by role (HA entities as ha:<entity_id>, local POST channels as source:<src>:<key>) |
/api/v1/sensors/manifest | GET | Declarative entity inventory for the HACS integration |
/api/v1/weather/history?hours=24 | GET | Recent observed-weather series (oldest to newest) for the headline fields; powers the dashboard sparklines |
/api/v1/weather/readings | GET | Recent 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.
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/radar/windgrid?bbox=minLon,minLat,maxLon,maxLat | GET | Wind 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,maxLat | GET | Short-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/tropical | GET | Basin-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.
| Endpoint | Method | Purpose |
|---|---|---|
/ingest/ecowitt | POST | Ecowitt console “custom upload” receiver (form-encoded) |
/ingest/webhook/{id} | POST | Generic 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": 1,
"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 readsstandbyorwatchinginstead; 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 instancedegraded;watching,standby, andfalling_throughare 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.
Backup and restore
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/backup | GET | tar.gz bundle: localsky.toml + a consistent copy of the database + manifest. Deliberately excludes the VAPID private key directory |
/api/v1/backup/restore | POST | Multipart restore (bundle, or bare config / db); the database swaps in at next boot |
/api/v1/backup/snapshots | GET | Config 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.