LocalSky
These docs track LocalSky v0.2.0-beta.1.
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 reads your weather station over the LAN (Tempest, Ecowitt, Ambient Weather, Davis, and more), merges Open-Meteo with regional forecast sources (NWS in the US, MET Norway, OpenWeather, Pirate Weather) with per-field provenance, and renders the result in a fast installable PWA with built-in radar (RainViewer worldwide, 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-hacs
- 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.
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 uid 10001, not root. If you mount a host directory instead (
-v /opt/localsky/data:/data), first runsudo chown -R 10001:10001 /opt/localsky/data, or start the container with--user 0:0.
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 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 Sensors hub. A one-click network scan finds Tempest and Ecowitt hardware on your LAN. Skipping is fine; sources can be added any time afterward.
- 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
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.)
- ESPHome sprinkler component (planned), DIY ESP32-based controllers
- Rachio Gen 2 / 3 (planned), cloud API, US$130-250 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
- docs/migration.md: internal Aperture Labs operator path
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
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.
ESPHome sprinkler (DIY)
For people who want full open hardware: an ESP32 + relay board + ESPHome’s sprinkler component. ~US$15-40 in parts. LocalSky’s esphome_native controller (planned) will speak the protobuf protocol directly. Until that adapter lands, enable MQTT on the ESPHome device and drive it with LocalSky’s mqtt_command controller; no HA needed.
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.
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.
Home Assistant integration
LocalSky ships a native Home Assistant integration, distributed through HACS from github.com/silenthooligan/localsky-hacs. 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.
Two pieces, in this order. LocalSky is a server you run yourself (one Docker container, see the Quick start); 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 0.2.0 or newer. 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). - 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:
- In Home Assistant, open HACS.
- Open the three-dot menu (top right) and choose Custom repositories.
- Add
https://github.com/silenthooligan/localsky-hacswith 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 VLANs, mDNS blocked), add it manually:
- Settings > Devices & Services > Add Integration, search for LocalSky.
- Enter the host (for example
192.168.1.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:
Units below are LocalSky’s native (imperial) reporting units; Home Assistant converts sensors with supported device classes (temperature, wind speed, precipitation, pressure, distance) to your configured unit system automatically.
| 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 VLANs 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 LocalSky 0.2.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-hacsas 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 |
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).
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.
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://192.168.1.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://192.168.1.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 |
| 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.
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.
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 in v0.1 |
|---|---|---|---|---|
| OpenSprinkler (boxed) | Direct HTTP on LAN | No | 130-180 | Tested |
| OpenSprinkler Pi | Direct HTTP on LAN | No | ~80 (Pi) + relay board | Tested |
| Home Assistant service call | HA REST | No (HA local) | Whatever HA drives | Tested |
| ESPHome sprinkler | ESPHome native API | No | 5-40 ESP32 + valves | Community / planned |
| Rachio Gen 2/3 | Rachio cloud API | Yes | 130-250 | Planned |
| Hunter Hydrawise | Cloud API | Yes | 130-300 | Community / planned |
| B-hyve | Cloud API | Yes | 80-150 | Community / planned |
| DryRun | No-op | No | None | Tested |
Prices are US retail; availability and cost vary by region. Rachio, B-hyve, and Hydrawise are sold mostly through North American retail; OpenSprinkler and ESPHome hardware ship worldwide, which makes them the natural picks outside North America too.
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 doesn’t have a native adapter for (Hunter, B-hyve via HA), but the HA integration does
- Wanting irrigation runs to flow through HA’s automation engine for additional logic
ESPHome sprinkler (community / planned)
ESPHome’s sprinkler component turns an ESP32 with a relay board into a smart irrigation controller for ~$15-40 total parts cost. The native API (protobuf over TCP) is documented.
[[controllers]]
id = "esp_irrigation"
default = true
kind = "esphome_native"
[controllers.config]
host = "192.0.2.20"
port = 6053
password = "${ESP_API_PASSWORD}"
[controllers.config.zone_entity_map]
back_yard = "switch.back_yard_valve"
front_yard = "switch.front_yard_valve"
Status: trait scaffolded, native adapter implementation deferred. Until then, run the ESPHome device under HA and use the ha_service_call controller. Track progress at the relevant GitHub issue.
Rachio Gen 2/3 (planned)
Rachio is cloud-tethered but well-documented. The v1 API takes a bearer token and exposes zone start, zone stop, schedule query.
[[controllers]]
id = "rachio_main"
default = true
kind = "rachio"
[controllers.config]
api_token = "${RACHIO_API_TOKEN}"
device_id = "..."
[controllers.config.zone_uuid_map]
back_yard = "..." # Rachio zone UUID
Status: schema variant exists, adapter implementation deferred. Until then, drive your Rachio through HA’s Rachio integration and use ha_service_call.
Hunter Hydrawise / B-hyve / others (community)
Both speak cloud APIs that HA integrations exist for. The LocalSky path until native adapters exist: drive them through HA + ha_service_call.
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.
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.
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.
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. |
| 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.
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 forecast highs cross the heat-advisory threshold.
- 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 LocalSky image runs as uid 10001, so make sure that user can read the PEM:
chown 10001:10001 ./localsky-keys/vapid-private.pem
chmod 400 ./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 morning advisory: a two-sentence plain-English summary of what will run, what will skip, and why.
- Answers “should I water before the party Saturday?” style questions against live state in the chat panel.
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).
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)
- Day-of-year + latitude + elevation
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.
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 17-rule ladder. First matching rule wins. Order encodes intent: explicit user overrides > paused > current-conditions safety (raining now, freeze, soil frost, wind) > soil saturation > forecast skips > heat advisory > dry-run > run.
Full enumeration in skip-rules.md. All thresholds are typed config fields in cfg.engine.skip_rules; defaults match v0.1 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 |
| 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.
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.”
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).
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
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.
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"]
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.
X-Forwarded-For and trusted networks
How LocalSky determines the client address, exactly:
- If the request carries an
X-Forwarded-Forheader, LocalSky uses the first hop (the left-most entry) of that header. - Otherwise it uses the TCP peer address of the connection.
That address drives two things: the trusted_networks login bypass and
the login/setup rate limiter.
LocalSky has no trusted-proxy list, so it cannot tell a proxy-set
X-Forwarded-For from a client-forged one. Any client that can reach
the LocalSky port directly can send
X-Forwarded-For: 192.168.1.50 and, if 192.168.1.0/24 is in
trusted_networks, walk straight past login. Deploy accordingly:
-
Never expose the LocalSky port directly to the internet with
trusted_networksset. Either leavetrusted_networksempty on an internet-reachable instance, or make sure the only route to LocalSky is through your reverse proxy (bind LocalSky to localhost or an internal Docker network, or firewall the port). -
Your proxy must overwrite the header, not append to it. With nginx, use the client address itself:
proxy_set_header X-Forwarded-For $remote_addr;Do not use
$proxy_add_x_forwarded_forin front of LocalSky: it appends the proxy-observed address to whatever the client sent, which leaves a forged address in the first-hop position LocalSky reads. Caddy (2.5+) and Traefik overwrite forwarded headers from untrusted clients by default, so their stock configs are safe. -
On a flat LAN with no proxy, 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.
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) |
/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.
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). - 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.2.0-beta.1): you decide exactly when to move and what release notes apply. Recommended while LocalSky is in beta. :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.
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 M0009 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.
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.2.0-beta.1
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 endpoint (POST /api/v1/config/rollback?to=<version>), but in this beta nothing records config snapshots yet, so it always answers 404. Treat backup bundles as your rollback mechanism for now.
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 GitHub releases API about once a day (a plain GET, no telemetry attached) and serves the result at:
curl http://localhost:8090/api/v1/updates
{
"current": "0.2.0-beta.1",
"latest": "v0.2.0",
"update_available": true,
"release_url": "https://github.com/silenthooligan/localsky/releases/tag/v0.2.0",
"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 GitHub for new LocalSky releases” makes your browser (not the server) fetch the latest release tag, at most once per 24 hours, and shows the result inline. It is stored per device and discloses that device’s IP to GitHub, 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.
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
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 image runs as uid 10001 by default. If your /data bind mount is owned by root on the host, LocalSky starts fine but cannot write to it: the setup wizard fails to save localsky.toml, and run history is disabled (the server logs the SQLite open failure and keeps running without it). Fix the ownership:
sudo chown -R 10001:10001 /opt/localsky/data
Or run the container as root with user: "0:0" in compose (works, but the chown is the cleaner fix).
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. LocalSky makes no calls home and 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 GitHub releases API, off by default, opt-in via
[updates].check_enabled. Nothing rides along with it. - 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, plus cloud and generic MQTT/webhook sources; see Weather + soil sensors. Irrigation: OpenSprinkler is the canonical direct-LAN controller, with HA service-call, MQTT, cloud (Rachio, Hydrawise, B-hyve), and others; see Controllers.
What does “beta” mean here?
The current release is v0.2.0-beta.1. 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 follows your units preference (Settings > Units): readouts convert to °C, mm, and km/h when you pick metric. 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. There is no usage reporting, no crash reporting, no analytics of any kind in the codebase. The only optional phone-anywhere behavior is the update check described above, and it defaults to off.
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]]
[[controllers]]
[zones.<slug>]
[llm]
[notifications]
[engine]
[[manual_schedules]]
[scripting]
[conditions]
[auth]
[network]
[updates]
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, ambient_weather, netatmo, yolink, lacrosse, tuya_cloud, ha_passthrough, mqtt, http_webhook, demo_replay. See src/config/schema.rs SourceKind enum for per-kind config fields.
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.
[[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, ha_service_call, esphome_native, rachio, hydrawise, bhyve, rainbird, mqtt_command, dry_run.
[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. ["192.168.1.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 GitHub releases API about once a day and serves the comparison at GET /api/v1/updates. Nothing self-updates; docker pull stays the upgrade mechanism. See Upgrading LocalSky.
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.
A config rollback endpoint exists (POST /api/v1/config/rollback?to=<version>, snapshot list at GET /api/v1/backup/snapshots), but this beta does not record config snapshots on save yet, so it always returns 404. Keep backup bundles as your config history for now.
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
- 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.2.0-beta.1",
"api_version": "1.6.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"
}
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.
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) |
/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 with a keep-alive every 15 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.
POST /api/v1/config/rollback?to=<version>
Restore a previous snapshot. Reachable even when the engine is degraded; use it to recover from a bad config push.
curl -X POST -H 'Authorization: Bearer lsk_...' \
'http://localhost:8090/api/v1/config/rollback?to=12'
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.
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/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": "run_sequence_now" }
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".set_pause_untilwithepoch: 0clears the vacation pause (same asclear_pause_until).run_sequence_nowtriggers the full irrigation sequence immediately, bypassing the skip-check.
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.
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 |
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.2.0-beta.1",
"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": "fresh"
}
],
"controllers": [
{ "id": "opensprinkler", "kind": "opensprinkler_direct", "default": true, "enabled": true }
],
"ha": { "env_configured": true, "reachable": true, "snapshot_source": "standalone" }
}
Per-source status is "fresh" (seen within 5 minutes), "stale" (5 minutes to 1 hour), or "offline" (over 1 hour, or never). 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.
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.
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.