Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LocalSky

These docs track LocalSky v0.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 knowChapter
What weather sources LocalSky can readWeather and soil sensors
How the engine decides whether to waterIrrigation engine + Skip rules in depth
Which grass species the catalog supportsGrass species catalog
Which soil textures the catalog supportsSoil texture catalog
Which controllers LocalSky drivesIrrigation controllers
Every config optionConfiguration reference
Every REST + SSE endpointREST + SSE API
Upgrade from v0.1Upgrading LocalSky
Something brokeTroubleshooting
Quick answersFAQ

Two ways to run it

LocalSky is designed to work well in either configuration:

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

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

Everything runs on your own hardware. 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.

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 run sudo chown -R 10001:10001 /opt/localsky/data, or start the container with --user 0:0.

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

Docker Compose

The same install as a docker-compose.yml:

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

volumes:
  localsky-data:

Once the container is up, open http://localhost:8090/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.

  1. Welcome: what LocalSky is and the Apache-2.0 license acknowledgement. No telemetry, no analytics, no email signup.
  2. Your location: search for your address (built-in geocoding) or enter latitude and longitude directly. Elevation improves the FAO-56 ET₀ math; the timezone autofills from an offline dataset whenever lat/lon change.
  3. Weather: add weather and sensor sources with the same editor used in the 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.
  4. Controller: add your irrigation controller with the same editor as Settings, test it live against the real hardware, and scan it for zones. Scanned stations can be imported as zone stubs.
  5. Zones: explains LocalSky’s zone model and shows the grass-species gallery so you pick the right species. Zone editing itself lives under /settings/zones after the wizard; zones imported from a controller scan arrive there pre-populated.
  6. AI advisor (optional): pick an LLM provider, or None. You can test the connection live before finishing. See llm.md.
  7. Notifications (optional): Web Push, MQTT, ntfy, Slack. All independent; none required.
  8. Account (optional): create the owner account (username plus a password stored as an argon2id hash). The account is created immediately and you are signed in on that browser; finishing setup switches authentication to required. Skipping leaves auth disabled. See authentication.md.
  9. Review & apply: a per-section summary with edit links back into each step. Save and finish writes the config and sends you to the dashboard.

After the wizard

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

Standalone vs Home Assistant integration

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

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

Path 1: Standalone (the default)

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

Setup:

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

What this gets you:

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

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

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

Full walkthrough: docs/hacs.md.

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

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

Setup:

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

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

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

Setup:

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

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

Remote reachability

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

Tailscale (easiest)

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

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

The dashboard works through Tailscale exactly as on localhost.

Reverse proxy with TLS (production)

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

Caddy example:

localsky.example.com {
    reverse_proxy localhost:8090
}

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

Cloudflare Tunnel

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

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

Local LAN only

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

Mobile PWA from a remote URL

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

Irrigation controllers

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

  • OpenSprinkler (firmware 2.1.9+), the ideal controller. Direct HTTP API on the LAN, no cloud, US$130-180 hardware (US pricing; varies by region).
  • OpenSprinkler Pi: same protocol as the boxed version; runs on a Raspberry Pi
  • Home Assistant service call: works with any HA-driven irrigation integration (opensprinkler HACS, irrigation_unlimited, rachio, esphome sprinkler component, hubitat sprinkler, etc.)
  • 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 typeUnlocks
Soil moisture (Ecowitt WH51 / WH52, Aqara, Sonoff)Per-zone saturation skip, soil-moisture projection, smarter dry-out detection
Soil temperatureSoil-frost skip rule (catches the “cold soil + sprinkler = frozen lawn” case better than air temp alone)
Rain gauge (separate from weather station)Improves rain-today accumulation accuracy
Lightning detectorPowers the lightning panel + safety skip during active storms
Flow meter (on controller)Validates actual delivered water vs. computed mm depth

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

Optional: Local LLM

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

Troubleshooting

  • Dashboard says “no zones”: the wizard hasn’t been run, or the zone editor was skipped. Visit /setup or /settings/zones.
  • Verdict shows “(weather rules only; soil rules offline)”: a soil moisture probe isn’t reporting. Check the source under /settings/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 -m should report aarch64). 32-bit Pi OS is not supported.

Next steps

Standalone Mode (No Home Assistant)

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

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

What “standalone” gets you (the short answer)

Everything. The full LocalSky feature set runs without HA:

  • Live weather dashboard (Tempest UDP / Open-Meteo / Ecowitt / NWS / etc.)
  • FAO-56 reference ET₀ with Hargreaves fallback
  • Per-zone water balance + MAD-driven scheduling
  • 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/+/SENSOR matches every Tasmota device’s SENSOR topic
  • Plain numeric payloads (Tasmota / ESPHome /state topics)
  • JSON payloads with arbitrary nesting and arrays via json_path. Examples:
    • "soil.moisture" reads obj["soil"]["moisture"]
    • "sensors.0.value" reads obj["sensors"][0]["value"]
  • Tasmota-style number-as-string payloads
  • Linear transforms: published_value * scale + offset for unit conversion or sensor calibration

Hardware that works this way

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

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

Path 2: Direct LAN adapters

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

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

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

Networking note: Tempest UDP

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

Path 3: HTTP webhook receiver

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

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

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

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

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

Controller dispatch without Home Assistant

You have several direct paths:

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

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:

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

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

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

The “I’m against HA” reality check

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

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

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

Already running HA? There’s a native integration

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

Summary table

CapabilityStandaloneHA + LocalSky (Mode 2: outbound)HA + LocalSky (Mode 3: HA-driven)
Weather dashboard
Engine (ET, 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 surfaceLocalSky /settingsLocalSky /settingsLocalSky /settings
LocalSky depends on HANoNoYes (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 retained homeassistant/.../config discovery topics before adding the integration (see Troubleshooting below).

What you get

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

Requirements

  • Home Assistant 2024.11.0 or newer (enforced by HACS).
  • LocalSky 0.2.0 or newer. The integration probes GET /api/v1/info during setup and refuses to pair with older instances (you will see a “service too old” error in the config flow).
  • 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:

  1. In Home Assistant, open HACS.
  2. Open the three-dot menu (top right) and choose Custom repositories.
  3. Add https://github.com/silenthooligan/localsky-hacs with category Integration.
  4. Search for LocalSky in HACS and install it.
  5. 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:

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

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

Options

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

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

Authentication

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

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

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

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

Entity reference

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

Weather

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

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.

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

Irrigation

EntityPlatformNotes
Irrigation verdictsensortoday’s run/skip verdict from the engine
Irrigation reasonsensorthe human-readable “why” behind the verdict
ET₀ todaysensormm
Days since rainsensordays since significant rain
Rain tomorrow probabilitysensor%
Heat multipliersensorengine’s heat adjustment factor
Water levelsensorcontroller water level %
Max wind, Min temp, Rain skipnumberskip-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 reachablebinary_sensorconnectivity diagnostic
Irrigation suspendedbinary_sensoron while a pause is active
Any zone runningbinary_sensoron while any zone runs

Per zone

EntityPlatformNotes
valve.<zone>valvethe canonical control: open = run (default duration from options), close = stop
<zone> runningbinary_sensordevice class running
<zone> soil bucketsensorengine bucket state, mm
<zone> soil moisturesensorlive probe %, unavailable when no probe assigned
<zone> soil temperaturesensor°F, native Ecowitt probes
<zone> soil ECsensorµS/cm, native Ecowitt probes
<zone> soil batterysensorprobe battery %
<zone> planned runsensorseconds planned for the next run
<zone> run todaysensorminutes actually run today
switch.<zone> runswitchlegacy 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.

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

Example automations

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

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

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

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

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

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

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

Outage behavior

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

Troubleshooting

LocalSky is not discovered. mDNS does not cross 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 stale sensor.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

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.

  1. Install LocalSky (Docker or the binary) and run the setup wizard: location, weather sources, your controller, zones.
  2. Controller: add it natively (the wizard can scan it for stations). This does not interfere with an existing HA integration reading the same hardware; both can watch it at once.
  3. Sensors: if some sensors only exist in HA (a Zigbee soil probe, a Z-Wave rain gauge), add an HA passthrough source (kind = "ha_passthrough") and map those entities. Everything else (Tempest, Ecowitt, forecast models) comes in natively. See sensors.md for a worked example.
  4. 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-hacs as 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

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

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

When Home Assistant is unavailable

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

PieceBehavior while HA is down
Direct controllers (OpenSprinkler, Rachio, Hydrawise, B-hyve, Rain Bird, MQTT)Unaffected. LocalSky talks to the hardware itself; schedules run normally.
HA passthrough source (kind = "ha_passthrough")LocalSky polls HA’s /api/states every 30 seconds. 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 controllerEvery valve command is an HTTP call into HA. With HA down the dispatch fails: LocalSky logs the failure, abandons that zone’s remaining cycle segments, moves on to the next zone, and does not retry until the next scheduled window. Nothing waters through this controller during the outage, which is exactly why this guide moves you onto a direct controller.

Phase 4: Clean up Home Assistant

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

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

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

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

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

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

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

What about the controller’s own HA integration?

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

Quick mapping reference

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

Sensors

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

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

Always-on baseline (no sensors required)

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

  • FAO-56 reference ET₀ (Hargreaves fallback when only temp range is available; Penman-Monteith when wind + solar + humidity show up)
  • Crop ET per zone from species-specific Kc curves
  • 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_poll source polls the gateway directly and records moisture, temperature, conductivity, and battery per probe; the ecowitt_local push receiver works too.
  • Any Home Assistant soil sensor entity: a Zigbee probe on ZHA, a Z-Wave probe, anything HA already knows about.

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:

  1. Shows the kind of data that would go there
  2. Names what additional logic the data unlocks
  3. Links directly to /settings/sources with hints for compatible sources

Example: the soil moisture panel renders as:

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

Once a source is providing the field, the tile 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

SensorDirect adapterVia HANotes
Tempest hub (UDP)Tested (v0.1)YesAir temp, humidity, wind, solar, lightning, rain, pressure
Ecowitt GW1100/GW2000 LANLive (v0.1)YesNative direct poll: /get_livedata_info for moisture/temp/EC/battery per channel, /get_cli_soilad for raw FDR AD used in calibration
Ecowitt WH51/WH52 (soil)Live (v0.1)YesPolled natively via gateway; LocalSky calibrates moisture per zone against dry/wet AD endpoints in its own config; battery-powered, 868/915 MHz
Aqara ZigbeeVia HAYesSoil moisture + temp probes; needs Zigbee coordinator
Sonoff ZigbeeVia HAYesSame as Aqara
Ambient WeatherPlannedYesCloud API; socket.io
AcuRite tipping bucketVia Ecowitt or HAYes
PurpleAir / AirGradientDisplay onlyYesNo engine integration
OpenSprinkler flow sensorNativeYesRead via /jc water level field

Adding a new sensor source

Same shape as adding a weather source. See CONTRIBUTING.md. 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

ControllerPathCloud required?Hardware cost (US$)Status in v0.1
OpenSprinkler (boxed)Direct HTTP on LANNo130-180Tested
OpenSprinkler PiDirect HTTP on LANNo~80 (Pi) + relay boardTested
Home Assistant service callHA RESTNo (HA local)Whatever HA drivesTested
ESPHome sprinklerESPHome native APINo5-40 ESP32 + valvesCommunity / planned
Rachio Gen 2/3Rachio cloud APIYes130-250Planned
Hunter HydrawiseCloud APIYes130-300Community / planned
B-hyveCloud APIYes80-150Community / planned
DryRunNo-opNoNoneTested

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

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

Where OpenSprinkler shines

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

Where OpenSprinkler falls short

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

Home Assistant service call (legacy continuity)

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

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

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

Use cases:

  • Migrating from an HA-driven irrigation setup without re-wiring schedules
  • Using a controller LocalSky 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:

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

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

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

Morning advisory

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

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

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

Skip rules and thresholds

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

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

Thresholds are tunable in Settings under Logic (and live-tunable from the Irrigation tab’s threshold sliders). The History tab’s “Why it skipped” panel aggregates which rules actually fired over the window, so you can see whether a threshold is doing real work or just noise.

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:

  1. Bucket deficit (mm): how far the zone’s modeled soil moisture sits below full. Rain and runs fill it; daily crop ET drains it.
  2. Crop coefficient (Kc): the species’ seasonal multiplier on reference ET (see the grass species catalog). Hemisphere-aware: south of the equator the curve shifts six months.
  3. Heat multiplier: optional extension when forecast highs cross the heat-advisory threshold.
  4. Throughput (mm/hr): how fast your sprinklers actually apply water, either measured (catch cups) or the catalog default for the head type.
  5. 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:

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

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

1. Generate the keypair

openssl produces exactly what LocalSky loads:

mkdir -p ./localsky-keys

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

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

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

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

2. Mount the key and set the environment

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

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

The 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-key distinguishes the two: 503 means keys, and 503 from POST /api/v1/push/subscribe with "history db not configured" means the database.
  • iOS does not show notifications: iOS 16.4+ supports Web Push but only for PWAs added to the home screen via Share -> Add to Home Screen. A regular Safari tab will not ring.
  • No notifications after subscribing: confirm the server side with GET /api/v1/push/vapid-key, then trigger a test by manually running a zone; the zone-start event should arrive within seconds. Check the container logs for push: send ... failed lines.

What fires when

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

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

AI advisor (optional)

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

What it does when enabled:

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

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

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

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

Implementation: src/engine/et0.rs. 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_water is 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:

columnsource
predicted_inThe morning’s forecast (forecast.daily[0].precipitation_sum). First write of the day wins.
observed_inThe day’s end-of-period observed rain from the merged snapshot. Updated as the day accumulates.
month1..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/bias returns the current-month multiplier plus the full 12-month table with sample counts.
  • Pure module: engine::forecast_bias::BiasModel::from_observations(observations, today, window) is callable from anywhere; ideal for backtests and replay against historical verdict logs.
  • 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 * multiplier upstream of skip_rules::evaluate so the morning verdict reflects the learned bias automatically.

Defaults and bounds

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

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

Where to read further

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

#RuleTriggerThresholdTunable?
1Manual override: skip tomorrowis_tomorrow && override_tomorrow == "skip"noneUI
2Manual override: run tomorrowis_tomorrow && override_tomorrow == "run"noneUI
3Vacation pause (timed)pause_until_epoch > now_epochnoneUI
4Vacation pause (toggle)is_paused == truenoneUI
5Currently rainingrain_intensity_now_in_hr > 0.010.01 in/hr (0.25 mm/hr)rain_now_in_hr
6Freeze risk nowtemp_now_f < min_temp_f38°F (3.3°C)min_temp_f
7Overnight freezetemp_min_24h_f < min_temp_f38°F (3.3°C)min_temp_f
8Soil frostsoil_temp_yard_min_f < frost_skip_soil_f35°F (1.7°C)frost_skip_soil_f
9Wind too high nowwind_now_mph > max_wind_mph10 mph (16 km/h)max_wind_mph
10Windy day forecastwind_max_today_mph > max_wind_mph + 5+5 mph (8 km/h) slackwind_forecast_slack_mph
11Already wetrain_today_in >= 0.050.05 in (1.3 mm)already_wet_in
12All zones soil-saturatedevery zone’s moisture % >= saturation thresholdper-zoneper-zone soil settings
13Rain in next 4 hoursrain_next_4h_in >= 0.100.10 in (2.5 mm)rain_next_4h_skip_in
14Tomorrow rain (confidence-weighted)forecast_in * prob/100 >= rain_skip_in0.25 in (6.4 mm), weightedrain_skip_in
153-day rain rolluprain_3day_weighted_in >= 1.5 * rain_skip_in1.5x multiplierrain_3day_factor
16Heat advisory (pre-water)3-day max >= 95°F (35°C) + humidity >= 60% + 2+ dry dayscompositeheat_advisory_*
17Dry-run modeis_dry_run == truenoneUI
-Default(no rule matched)nonerun

Verdict types

The ladder returns one of three verdicts:

  • skip: don’t irrigate. reason carries a human-readable explanation.
  • run: proceed with the engine’s computed runtime.
  • run_extended: 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:

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

Contributing a species

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

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

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

Soil Texture Catalog

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

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

Catalog

Values per FAO-56 Table 19 + USDA NRCS Part 652 Table 11-3.

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

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

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

Jar test

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

When in doubt

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

Contributing a texture

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

Further reading

Authentication

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

Modes

[auth]
mode = "required"        # "disabled" (default for upgrades) | "required"
session_ttl_days = 30    # rolling browser-session lifetime
trusted_networks = []    # CIDRs that skip login, e.g. ["10.0.0.0/24"]
  • 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:

  1. If the request carries an X-Forwarded-For header, LocalSky uses the first hop (the left-most entry) of that header.
  2. 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_networks set. Either leave trusted_networks empty 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_for in 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_networks is fine there as long as the network itself is trusted.

What stays public

These paths never require credentials, by design:

PathWhy
/pkg/*, /sw.js, root static assetsCompiled assets; browsers fetch them without credentials
/api/v1/infoPairing probe; carries auth_required so clients know to ask for a token
/login, /api/v1/auth/{status,login,setup}The way in
/ingest/*, /api/v1/ingest/*Weather hardware (Ecowitt consoles, webhooks) cannot authenticate; block at the proxy for internet-facing deployments (details)
/api/v1/healthLiveness for Docker healthchecks; anonymous callers get a trimmed body (no source, controller, or HA detail)
/setup + wizard APIsOnly until the first account exists

Accounts

One owner account for now. Create it in the wizard’s Account step, or later under Settings, then Account. Passwords are stored as argon2id hashes. Sign-in attempts are rate limited per client address.

API tokens (integrations)

Integrations authenticate with long-lived API tokens sent as Authorization: Bearer lsk_...:

  1. In LocalSky: Settings, then Account, then Create token (name it, e.g. home-assistant).
  2. The plaintext is shown exactly once; store it where the integration asks for it. Only a hash is kept server-side.
  3. Revoke any token from the same screen; the Home Assistant integration starts its reauthentication flow automatically on the next 401.

SSE streams accept ?access_token=lsk_... as a query parameter for clients that cannot set headers. It is honored only on paths ending in /stream and ignored everywhere else (the browser EventSource sends the session cookie automatically, so this is only for external consumers).

Lockout recovery

If you lose the owner password, stop the container and delete the users rows from the database, then restart and re-run account creation:

sqlite3 /path/to/data/irrigation.db "DELETE FROM auth_sessions; DELETE FROM api_tokens; DELETE FROM users;"

Physical access to the data volume is the trust anchor, the same as Home Assistant’s.

Reverse proxy and HTTPS

LocalSky listens on plain HTTP (default :8090). On a trusted LAN with built-in auth enabled that is a reasonable place to stop. To reach it from the internet, put a TLS reverse proxy in front and let it terminate HTTPS.

Three things matter for any proxy:

  1. Pass X-Forwarded-Proto: https so LocalSky marks its session cookie Secure.
  2. Overwrite (never append to) X-Forwarded-For with the real client address. LocalSky reads the first hop of that header for auth.trusted_networks and login rate limiting, and it has no trusted-proxy list, so an appended header leaves a client-forged address in the position LocalSky trusts. See X-Forwarded-For and trusted networks.
  3. Server-Sent Events (/api/v1/stream, /api/v1/irrigation/stream, /api/v1/forecast/stream, plus their legacy /api/* aliases) are long-lived responses: disable buffering and give them a long (or no) read timeout.

What to expose

Built-in auth gates most of the app, but a few paths are public by design. For an internet-facing deployment, narrow them at the proxy:

  • Block /ingest/* and /api/v1/ingest/* from the internet. These receive sensor data from hardware that cannot authenticate (Ecowitt consoles, webhook devices), so they are exempt from auth. Anyone who can POST to them can feed LocalSky fabricated weather, and fabricated weather steers irrigation decisions. Your weather hardware is on your LAN; the internet has no business reaching these paths.
  • Consider blocking /setup and /api/v1/wizard/* until setup is done. The setup wizard (pages and APIs) is public until the first account exists, so a brand-new instance exposed before you finish the wizard can be configured by whoever finds it first. Either complete the wizard before exposing the instance, or block these paths at the proxy until you have created the owner account (after that, LocalSky locks them itself).
  • Keep /pkg/* and /sw.js reachable without credentials. These hydration assets are fetched by the browser without cookies; if a proxy-side auth layer intercepts them, the app shell breaks (see the warnings in each proxy section below).

Everything else (dashboard pages, the API, uploaded photos) is covered by LocalSky’s own auth when [auth] mode = "required". If you run with auth disabled, the proxy is your only gate; in that case put proxy-side auth in front of everything except /pkg/*, /sw.js, and (if hardware posts from outside) the ingest paths.

Caddy

localsky.example.com {
    reverse_proxy 127.0.0.1:8090 {
        flush_interval -1   # stream SSE unbuffered
    }
}

Caddy sets the forwarding headers and provisions certificates automatically, and (since 2.5) ignores forwarded headers from untrusted clients, so the X-Forwarded-For LocalSky sees is the real client address. If you also gate with Caddy-side auth (forward_auth, OAuth plugins), exempt /pkg/* and /sw.js: hydration assets are fetched without credentials and a redirect there breaks the app shell.

To block the ingest receivers from the internet with Caddy:

localsky.example.com {
    @ingest path /ingest/* /api/v1/ingest/*
    respond @ingest 403

    reverse_proxy 127.0.0.1:8090 {
        flush_interval -1
    }
}

nginx

server {
    listen 443 ssl;
    server_name localsky.example.com;
    # ssl_certificate ...; ssl_certificate_key ...;

    # Block unauthenticated receivers from the internet.
    location ~ ^/(ingest|api/v1/ingest)/ {
        return 403;
    }

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # SSE: no buffering, no read timeout.
    location ~ /stream$ {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 24h;
    }
}

Note X-Forwarded-For $remote_addr, not $proxy_add_x_forwarded_for. The latter appends to whatever X-Forwarded-For the client sent, and LocalSky reads the first (client-controlled) hop, which would let an internet client spoof a trusted_networks address and bypass login. $remote_addr replaces the header with the address nginx actually saw.

If nginx itself sits behind another proxy you control (e.g. a Cloudflare tunnel), use the real_ip module to recover the true client address first, and still send LocalSky a single-value header.

Traefik (Docker labels)

services:
  localsky:
    # ... your localsky service ...
    labels:
      - traefik.enable=true
      - traefik.http.routers.localsky.rule=Host(`localsky.example.com`)
      - traefik.http.routers.localsky.entrypoints=websecure
      - traefik.http.routers.localsky.tls.certresolver=letsencrypt
      - traefik.http.services.localsky.loadbalancer.server.port=8090

Traefik streams responses by default and, unless you opt in to forwardedHeaders.insecure or trustedIPs, discards forwarded headers from untrusted clients and sets its own, which is what LocalSky needs.

If you add a Traefik auth middleware (forwardAuth, basicAuth, OAuth) in front of LocalSky, exempt /pkg/* and /sw.js from it (a higher-priority router for those path prefixes without the middleware). Hydration assets are fetched without credentials; gating them breaks the app shell exactly as it does with Caddy or nginx.

Home Assistant integration through a proxy

The HACS integration talks to whatever host/port you pair it with. On the LAN, pair it straight to :8090 (with an API token when auth is required) and keep the proxy for browsers; nothing else is needed.

Upgrading LocalSky

LocalSky ships as a single Docker image. Upgrading means pulling a newer image and recreating the container. Everything that matters lives in /data (your config file and the SQLite database), so the container itself is disposable: stop it, remove it, start a new one on the same volume, and you are back where you were, on the new version.

Back up first

Before any upgrade, download a backup bundle. It takes one click (Settings -> Advanced -> Download backup) or one command:

curl -fL -o localsky-backup.tar.gz http://localhost:8090/api/v1/backup

If you enabled authentication, add -H "Authorization: Bearer lsk_..." with an API token. See Backup, restore, and recovery for everything the bundle contains and how to restore it. A pre-upgrade backup is also your downgrade path, so do not skip it.

Choosing a tag

The image is published at ghcr.io/silenthooligan/localsky:

  • Pinned version (ghcr.io/silenthooligan/localsky:v0.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 routine docker compose pull can move you across versions without you reading the release notes first.

Either way, read the release notes on GitHub before upgrading. Releases that change the database or config schema say so explicitly.

The upgrade

With plain docker run (matching the install command from Quick start):

docker pull ghcr.io/silenthooligan/localsky:latest
docker stop localsky && docker rm localsky
docker run -d \
  --name localsky \
  --restart unless-stopped \
  -p 8090:8090 \
  -v /opt/localsky/data:/data \
  ghcr.io/silenthooligan/localsky:latest

With Docker Compose:

docker compose pull
docker compose up -d

Removing the container does not touch /data. Your config, run history, sensor history, and login accounts all survive the recreate.

Auto-updaters (Watchtower, Diun notifications, Renovate on a pinned compose file) work fine with this image. Pair them with a scheduled backup if you let them act unattended.

What happens on first boot after an upgrade

  1. Database migrations run. LocalSky keeps a chain of numbered SQLite migrations (M0001 through M0009 as of this release) and records each applied one in a schema_migrations table. On boot it applies only the ones your database has not seen yet. Each migration runs inside a single transaction, so a failure rolls back cleanly rather than leaving a half-migrated database. Skipping releases is fine: the chain applies in order, however many versions you jumped.
  2. The config file loads. /data/localsky.toml carries a schema_version field (currently 1). 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.
  3. The app comes up at the same address with the same data, zones, and history.

No manual migration steps. If a migration fails, the error appears in docker logs localsky with the migration version that failed.

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_version above what the running binary supports, the loader refuses it with refusing to load a config newer than this binary and LocalSky boots as if unconfigured rather than guessing. Restore the pre-upgrade localsky.toml from your backup (or re-upgrade). As of this release schema_version is still 1, so this cannot bite you yet.

There is also a config rollback 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.db that predates the migration runner is detected on first boot. The legacy runs table is rebuilt into the current schema with every historical row preserved (your watering history carries forward), and existing web push subscriptions are kept as-is.
  • /data/localsky.toml, if the wizard already wrote one, loads unchanged: schema_version = 1 then is schema_version = 1 now.
  • New v0.2 surfaces (authentication, the /api/v1/* API prefix, backup endpoints) start in their defaults: auth stays disabled until you create an owner account, and the old bare /api/* paths still work for existing clients.

Take a copy of /data before the first v0.2 boot anyway. The runs-table rebuild is one-way, and a 30-second tar czf localsky-v01.tar.gz -C /opt/localsky data is cheap insurance.

Backup, restore, and recovery

Everything LocalSky knows lives in the /data directory you mounted at install time. Back that up and you can rebuild a working instance on any machine in minutes.

What is in /data

FileWhat it holds
localsky.tomlYour entire configuration: location, sources, controllers, zones, schedules, restrictions, notification channels
irrigation.dbThe SQLite database: run history, sensor history, verdict history, decision traces, web push subscriptions, and (when auth is enabled) accounts, sessions, and API tokens
irrigation.db-wal, irrigation.db-shmSQLite write-ahead-log sidecars; present while the container runs
localsky.toml.draftFirst-run wizard progress, if you saved mid-wizard; deleted when the wizard finishes
instance-idA stable random identity used for mDNS and Home Assistant pairing
site/photos/Zone photos uploaded through the zone editor

The database runs in WAL mode, so even an unclean shutdown rolls back to a consistent state on the next boot.

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_PATH points). A casually shared backup should not leak a signing key; copy it separately if you use web push.
  • instance-id. Restoring a bundle onto new hardware mints a new identity on purpose.
  • Zone photos (/data/site/photos/). Copy that directory yourself if the photos matter to you.

Offline alternative

No API needed; plain files work too.

While running (WAL mode makes a SQLite-aware copy safe):

# Bind mount, as in the install docs:
sqlite3 /opt/localsky/data/irrigation.db \
  ".backup '/backup/localsky/irrigation-$(date +%F).db'"
cp /opt/localsky/data/localsky.toml /backup/localsky/localsky-$(date +%F).toml

# 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 as irrigation.db.pre-restore.<timestamp>, so a restore is reversible) and swaps the staged one in. That is why the response says "restart_required": true whenever 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.

Troubleshooting

This page is keyed by symptom. Find the thing that looks wrong, follow the steps. When in doubt, start with the first section: almost every problem shows its face in the logs or the health endpoint before it shows anywhere else.

Logs and health first

Read the logs

docker logs -f localsky

Log verbosity is controlled by the standard RUST_LOG environment variable (the server uses tracing with an env filter; if RUST_LOG is unset it defaults to info). To get engine, source, and controller detail without drowning in HTTP transport noise:

docker run ... -e RUST_LOG=info,localsky=debug ...

Restart the container after changing it.

Ask the health endpoint

curl -s http://localhost:8090/api/v1/health | jq

What the fields mean:

  • status is a three-step ladder:
    • wizard: no config file exists yet. Visit /setup.
    • ok: config loaded and every enabled source is reporting.
    • degraded: the config file exists but failed to load, or at least one enabled source is offline.
  • sources[]: one entry per configured source with last_seen_epoch, stale_for_s, and a status of fresh, stale, or offline. For live sources (stations, soil sensors) the windows are: fresh under 5 minutes, stale from 5 minutes to 1 hour, offline past 1 hour (or never seen). Polled forecast sources (Open-Meteo, NWS, OpenWeather, Pirate Weather, MET Norway, Netatmo) refresh on a roughly 30 minute cadence, so they get wider windows: fresh under 65 minutes, offline past 3 hours.
  • controllers[]: id, kind, whether it is the default, and whether it is enabled.
  • ha: the Home Assistant relationship in both directions: env_configured (HA_URL set), reachable (last HA poll succeeded), snapshot_source (standalone or home_assistant), mqtt_discovery (outbound MQTT publishing on), hacs_last_seen_epoch and hacs_streaming (whether the Home Assistant integration has fetched the manifest or is holding a live event stream right now).

If authentication is enabled and you call /api/v1/health without credentials, you get a trimmed body: status, config_present, version, uptime_s, and subsystems only. Sources, controllers, and the ha block are removed so an anonymous probe cannot map your network. Docker healthchecks and uptime monitors keep working either way.

Compose healthcheck

The image ships a built-in HEALTHCHECK that curls http://127.0.0.1:8090/api/v1/info every 30 seconds. If you move LocalSky off port 8090, override it in compose:

services:
  localsky:
    # ...
    healthcheck:
      test: ["CMD", "curl", "--fail", "--silent", "--max-time", "4", "http://127.0.0.1:8091/api/v1/info"]
      interval: 30s
      timeout: 5s
      start_period: 30s
      retries: 3

/api/v1/info is the cheapest liveness probe. Use /api/v1/health 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 -m should report aarch64. 32-bit Pi OS is not supported.
  • LocalSky idles around 30 MB resident, so nothing special is needed beyond that. The SQLite database sees light write traffic (run rows, sensor samples), which is fine on an SD card, though an SSD never hurts.

Weather sources

Tempest station shows no data

The Tempest hub broadcasts UDP packets on port 50222 to your LAN’s broadcast address. Docker’s default bridge networking does not deliver broadcast traffic into a container, so a bridge-networked LocalSky never hears the hub even though everything looks configured. Run with host networking:

services:
  localsky:
    network_mode: host

To confirm packets are actually arriving on the host:

sudo tcpdump -i any -c 3 udp port 50222

If tcpdump sees packets and LocalSky still shows nothing, check the source is enabled under Settings, then Sources, and watch docker logs for parse errors.

Ecowitt discovery finds nothing

Discovery works by sending a broadcast datagram on UDP 46000 and listening about 3 seconds for gateway replies. Two requirements:

  1. Host networking (same broadcast limitation as Tempest above).
  2. The gateway must be on the same subnet as the LocalSky host.

If discovery still comes back empty, skip it and add the gateway manually: create an ecowitt_gw_poll source under Settings, then Sources, and enter the gateway’s IP address. Alternatively, point the gateway’s own custom upload (WSView Plus or the console UI) at LocalSky’s receiver: protocol Ecowitt, path /ingest/ecowitt, your LocalSky host and port.

A source went stale: what happens to watering?

Nothing dramatic, by design. When an enabled source crosses the offline threshold:

  • /api/v1/health flips to degraded.
  • A dismissable banner appears at the top of the UI naming the offline source(s), with a link to the Sensors hub. Dismissing it snoozes that exact set of sources for the session; a new failure re-raises it.
  • The engine keeps deciding from the freshest data it has. Field merging picks the highest-priority source with a recent observation (ties broken by recency), and rain totals take the max across sources so one dead gauge cannot mask real rain. Sensor-dependent extras (soil-saturation skip, for example) sit out while their probe is silent; the weather and ET math stays on.

Controllers

Controller was offline when watering should have started

Runs do not queue. When the morning scheduler dispatches a zone and the controller call fails, LocalSky logs a warning (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 controllers block in /api/v1/health: is the controller enabled, and is one marked default?
  • Runs started from the controller’s own app or front panel show up via the status poll, but they were not planned by LocalSky and may not appear in its run history the way engine-dispatched runs do.

Verify wiring with the DryRun controller

Before trusting a new setup with real valves, add a controller of kind dry_run. Every dispatch is logged (dry_run: would have run zone ...) instead of actuated, and with simulate_runs enabled it writes completed rows to the runs table so the dashboard and history render exactly as they would for real hardware. The wizard’s zone scan against a DryRun controller returns sample zones (Front Lawn, Back Lawn, Garden Beds) so you can rehearse the full add, test, scan, import flow with zero hardware. See Controllers.

Watering decisions

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._tcp on the LAN so clients like the Home Assistant integration can find it without you typing an IP.

Configuration reference

LocalSky’s configuration is a single TOML file at /data/localsky.toml. The first-run wizard writes it; the settings UI edits it; every PUT /api/v1/config validates and then writes it atomically (write to a temp file, rename). Schema lives in src/config/schema.rs.

This document is the field-by-field reference. The wizard (docs/getting-started.md) is the conversational walkthrough; this is the lookup table.

Top-level structure

schema_version = 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 degrees
  • location.elevation_m: optional, used by FAO-56 net-radiation
  • units: "metric" or "imperial". The setup wizard pre-selects this from your location; existing configs keep their value. Configs written without the field fall back to "imperial" for backward compatibility. Per-field overrides live in browser localStorage, not here
  • timezone: optional IANA name. Null derives from lat/lon at boot
  • display_name: surfaces in the MQTT discovery node_id (slugified) and the dashboard title

[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:

  • mqtt subscribes to broker topics (Tasmota, ESPHome, Zigbee2MQTT, any raw publisher). Config: broker_host, broker_port (default 1883), optional username/password, and a subscriptions list mapping each topic to a weather field with optional scale/offset.
  • http_webhook accepts JSON POSTs at a path you choose under /ingest/ from anything that can speak HTTP (Arduino, a Pi script, a commercial gateway). Config: path, optional shared-secret token (sent as the X-LocalSky-Token header or ?token= query parameter), and a fields mapping list.

priority matters when multiple sources report the same field. Convention: 100 = LAN station; 50 = forecast model; 10 = fallback.

[[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_version must equal or be less than what the binary supports
  • Source ids and controller ids must be unique
  • Exactly one controller can have default = true (zero is allowed only when [[controllers]] is empty)
  • Each zone’s controller_id must reference a configured controller
  • lat in [-90, 90], lon in [-180, 180]

Bad PUTs return 422 with the specific failure; on-disk file is untouched.

Migrations

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

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_required tells a client whether it must present credentials before touching anything else. Integration clients (the HACS integration) read this on probe and prompt for an API token.
  • uuid is the stable per-install id, also broadcast in the mDNS TXT record (_localsky._tcp.), so clients can dedupe an instance across IP or hostname changes.
  • dry_run and demo flag instances running with LOCALSKY_SMART_DRY_RUN=1 or LOCALSKY_DEMO=1.

Authentication

LocalSky ships built-in authentication (API 1.6.0+). It is policy-driven: [auth] mode = "disabled" (the default for upgraded installs) leaves every endpoint open, mode = "required" gates everything except the public set below. See the Authentication guide for setup, accounts, and trusted_networks.

Credentials

When auth is required, the middleware accepts credentials in this order:

  1. Authorization: Bearer lsk_...: a long-lived API token created under Settings, then Account. This is what integrations (HACS, scripts, dashboards) should use.
  2. ?access_token=lsk_...: the same API token as a query parameter, accepted only on paths ending in /stream (browser EventSource cannot set headers). It is ignored everywhere else.
  3. Session cookie: localsky_session=lss_..., set by POST /api/v1/auth/login. HttpOnly, SameSite=Lax, marked Secure when the request arrived over HTTPS (detected via X-Forwarded-Proto). Lifetime is session_ttl_days.

Requests from a trusted_networks CIDR skip credentials entirely; read how the client address is determined before relying on this.

Unauthenticated outcomes: HTML GETs are redirected (302) to /login; API calls get 401 with body {"error": "unauthorized"} and a WWW-Authenticate: Bearer realm="localsky" header.

Public paths

These are exempt from authentication, straight from the middleware’s exemption table:

PathWhy it is public
/pkg/*, /sw.jsCompiled hydration assets and the service worker; browsers fetch these without credentials, so gating them breaks the app
Root-level static files (/favicon.ico, /manifest.webmanifest, and any single-segment path ending in .svg .png .ico .webmanifest .woff2 .woff .css .js .map .txt)Browsers fetch manifests and icons without credentials. Uploaded photos under /site/photos/* stay protected
/api/v1/info, /api/infoPairing probe; carries auth_required so clients know to ask for a token
/login, /api/v1/auth/status, /api/v1/auth/login, /api/v1/auth/setup (and the /api/auth/* aliases)The way in. setup only succeeds while zero accounts exist
/ingest/*, /api/v1/ingest/*Weather hardware (Ecowitt consoles, webhook devices) cannot authenticate. See what to expose through a proxy
/api/v1/health, /api/healthAlways reachable for Docker healthchecks, but anonymous callers get a trimmed liveness-only body (no source, controller, or HA detail)
/setup, /setup/*, /api/v1/wizard/*, /api/wizard/*Only until the first account exists, so docker run -> browser -> wizard works; locked once setup completes

Everything else, including every other /api/v1/* endpoint, the dashboard pages, and /site/photos/*, requires credentials.

Cross-origin behavior

LocalSky sends no CORS headers, so browsers block cross-origin reads of the API by default; call it from the same origin or from server-side code. Additionally, when auth is required, any non-GET request whose Origin header disagrees with the Host header is rejected with 403 (CSRF hardening alongside the SameSite=Lax cookie). Non-browser clients send no Origin header and pass.

Auth endpoints

EndpointMethodPurpose
/api/v1/auth/statusGET{ mode, setup_complete, authenticated }; always public
/api/v1/auth/setupPOSTCreate the first owner account {username, password}; 409 once one exists
/api/v1/auth/loginPOSTSign in {username, password}; sets the session cookie
/api/v1/auth/logoutPOSTClear the session
/api/v1/auth/sessionGETCurrent user (401 when anonymous and auth is required)
/api/v1/auth/tokensGET / POSTList / create API tokens ({name} -> {token}, shown exactly once)
/api/v1/auth/tokens/{id}DELETERevoke a token

Login and setup are rate limited to 10 attempts per minute per client address.

Snapshot endpoints (read-only)

These serve the dashboard’s primary data. Both REST (one-shot) and SSE (push-on-change) variants exist for every snapshot type. All SSE feeds emit events named snapshot 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.

EndpointMethodPurpose
/api/v1/wizard/draftGET / PUT / DELETERead, save, or discard the wizard draft
/api/v1/wizard/applyPOSTValidate the draft and write it as the live config
/api/v1/wizard/stateGETWizard progress state
/api/v1/wizard/seed_currentPOSTSeed the draft from the current live config (re-running the wizard)
/api/v1/wizard/test_sourcePOST{ "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_controllerPOST{ "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_llmPOST{ "llm": <LlmConfig> }; live probe of the configured LLM provider
/api/v1/wizard/scan_zonesPOST{ "controller": <ControllerEntry> }; zone discovery for controllers that support it, pre-populates the zone editor
/api/v1/wizard/discoverGETOne LAN sweep: passive Tempest, Ecowitt broadcast, OpenSprinkler probe
/api/v1/wizard/geocode?q=<address>GETServer-side proxy to Nominatim with the required User-Agent

geocode returns up to 5 candidates:

[
  {
    "display_name": "Orlando, Florida, USA",
    "lat": "28.5383",
    "lon": "-81.3792"
  },
  {
    "display_name": "Cambridge, Cambridgeshire, England, United Kingdom",
    "lat": "52.2053",
    "lon": "0.1218"
  }
]

Irrigation control endpoints

POST /api/v1/irrigation/action

Dispatch a controller action. The body is a tagged enum; shape varies by kind:

{ "kind": "run", "zone": "back_yard", "seconds": 600 }
{ "kind": "stop", "zone": "back_yard" }
{ "kind": "stop_all" }
{ "kind": "set_threshold", "key": "max_wind_mph", "value": 12.0 }
{ "kind": "toggle", "key": "irrigation_pause", "on": true }
{ "kind": "set_pause_until", "epoch": 1765500000 }
{ "kind": "clear_pause_until" }
{ "kind": "set_override_tomorrow", "mode": "skip" }
{ "kind": "run_sequence_now" }

Notes:

  • run is clamped server-side to 7200 seconds (2 hours) regardless of what the client sends.
  • set_threshold accepts only the known keys max_wind_mph, min_temp_f, rain_skip_in.
  • set_override_tomorrow takes "none" | "skip" | "run".
  • set_pause_until with epoch: 0 clears the vacation pause (same as clear_pause_until).
  • run_sequence_now triggers 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).

EndpointMethodPurpose
/api/v1/sensors/soilGETSoil-moisture channels for the zone picker
/api/v1/sensors/discoveredGETEvery relevant entity LocalSky can see, grouped by role (HA entities as ha:<entity_id>, local POST channels as source:<src>:<key>)
/api/v1/sensors/manifestGETDeclarative entity inventory for the HACS integration
/api/v1/weather/history?hours=24GETRecent observed-weather series (oldest to newest) for the headline fields; powers the dashboard sparklines
/api/v1/weather/readingsGETRecent raw readings from the sensor-history table

Web Push endpoints

GET /api/v1/push/vapid-key

Public VAPID key for browser subscription. Returns { "public_key": "<base64url>" }, or 503 with { "error": "vapid not configured" } when no keypair is loaded. See Notifications for key generation.

POST /api/v1/push/subscribe

Body: the PushSubscription JSON from the browser’s pushManager.subscribe() ({ endpoint, keys: { p256dh, auth } }). Idempotent upsert; returns { "ok": true }.

POST /api/v1/push/unsubscribe

Body: { "endpoint": "..." }. Returns { "ok": true, "removed": <n> }.

Both subscribe endpoints return 503 if the history database was not openable at startup.

Zone photos

POST /api/v1/zones/photo

Multipart upload, field name file. Accepts jpg, jpeg, png, gif, webp up to 10 MB (SVG is rejected because it can carry script). Returns { "url": "/site/photos/...", "filename": "..." }. The served photos under /site/photos/* require authentication.

Ingest endpoints

Push-style sensor receivers. Mounted at /ingest/* and /api/v1/ingest/*, and unauthenticated by design because the posting hardware cannot hold credentials; per-source path secrets are the mitigation. Do not expose these to the internet: see what to expose.

EndpointMethodPurpose
/ingest/ecowittPOSTEcowitt console “custom upload” receiver (form-encoded)
/ingest/webhook/{id}POSTGeneric HTTP webhook receiver for the configured webhook source {id}

Both return 200 on successful parse so misconfigured downstreams do not trigger retry storms on the device.

Health and meta

GET /api/v1/health

Liveness + readiness, always reachable. Authenticated (or auth-disabled) callers get the full structured body:

{
  "status": "ok",
  "config_present": true,
  "version": "0.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

EndpointMethodPurpose
/api/v1/backupGETtar.gz bundle: localsky.toml + a consistent copy of the database + manifest. Deliberately excludes the VAPID private key directory
/api/v1/backup/restorePOSTMultipart restore (bundle, or bare config / db); the database swaps in at next boot
/api/v1/backup/snapshotsGETConfig snapshot history feeding POST /api/v1/config/rollback

Service worker and PWA

GET /sw.js

Service worker script. Version interpolated server-side from CARGO_PKG_VERSION so every deploy bumps the SW version. Always public.

GET /manifest.webmanifest

PWA manifest. Static and always public.

Client tooling

A minimal Python client to round-trip the config:

import requests

base = 'http://localhost:8090'
headers = {'Authorization': 'Bearer lsk_...'}  # omit if auth is disabled

cfg = requests.get(f'{base}/api/v1/config', headers=headers).json()
# Secret fields arrive as "***redacted***"; leave them unchanged and
# the server preserves the stored values on PUT.

cfg['engine']['skip_rules']['max_wind_mph'] = 12.0

r = requests.put(f'{base}/api/v1/config', json=cfg, headers=headers)
if r.status_code == 200:
    print('saved', r.json()['saved'])
else:
    print('rejected:', r.json())

JavaScript / shell / Rust clients follow the same shape.