LocalSky
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 Tempest or Ecowitt station over the LAN, merges Open-Meteo and NWS forecasts with per-field provenance, and renders the result in a fast installable PWA with built-in NEXRAD radar 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, ESPHome, 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 accepts an empty list. 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 outbound MQTT discovery path and the legacy Smart Irrigation + Irrigation Unlimited passthrough.
Where things live
| What you want to know | Chapter |
|---|---|
| What weather sources LocalSky can read | Sensors |
| How the engine decides whether to water | Irrigation engine + Skip rules |
| Which grass species the catalog supports | Grass species catalog |
| Which soil textures the catalog supports | Soil texture catalog |
| Which controllers LocalSky drives | Controllers |
| Every config option | Configuration reference |
| Every REST + SSE endpoint | REST + SSE API |
| How the UI is supposed to feel | UX journey |
| Upgrade from v0.1 | Migration from v0.1 |
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: an outbound MQTT discovery publisher auto-creates
sensor.localsky_*entities in HA. A passthrough adapter is also available for existing Smart Irrigation + Irrigation Unlimited setups.
Both modes are first-class. Pick the one that fits your stack.
Everything runs on your own hardware. Open-Meteo is the only optional outbound call, and it can be swapped for NWS or any other compatible forecast source.
Project links
- Source: github.com/silenthooligan/localsky
- HACS integration: github.com/silenthooligan/localsky-hacs
- Issues + discussions: same repos
- License: Apache-2.0
Getting Started with LocalSky
This guide takes you from “no LocalSky installed” to “watching real weather and managing real zones” in about 15 minutes. Two paths: Demo mode if you just want to see the UI, and Real install if you have hardware.
Prerequisites
LocalSky is delivered as a Docker image. Anywhere Docker runs, LocalSky runs:
- Linux (any distro; native Docker)
- macOS (Docker Desktop, OrbStack, or colima)
- Windows (Docker Desktop with WSL2 backend)
- Synology / QNAP NAS (Container Manager)
- Raspberry Pi 4 or 5 (64-bit OS, multi-arch image ships arm64)
- Unraid, Proxmox LXC, 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 Florida summer day at 10× wall-clock rate. No external network calls except the Leaflet stylesheet for the radar map.
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
mkdir -p /opt/localsky/data
docker run -d \
--name localsky \
--restart unless-stopped \
-p 8090:8090 \
-v /opt/localsky/data:/data \
-e LOCALSKY_V2=1 \
ghcr.io/silenthooligan/localsky:latest
/opt/localsky/data (or wherever you point the -v mount) is where the config file and SQLite database live. Adjust the host path to fit your filesystem.
LOCALSKY_V2=1 opts into the new wizard + settings UI; without it, you’d be on the legacy single-config path that requires hand-editing env vars.
Visit http://localhost:8090. The dashboard redirects you to /setup because there’s no config file yet.
First-run wizard
Eight steps; none take more than a minute.
- Welcome: accept the Apache-2.0 license. Telemetry defaults off.
- Location: latitude + longitude in decimal degrees. Elevation optional; improves FAO-56 ET₀. Timezone optional too (derives from lat/lon at boot).
- Sources: informational. Auto-creates a Tempest UDP listener (in case you have one) + Open-Meteo forecast. Full editor under
/settings/sourcespost-wizard. - Controllers: informational. Auto-detects HA env vars and creates an HA-service-call controller if present; otherwise add one under
/settings/controllers. - Zones: informational. Configure under
/settings/zones. - LLM: pick a provider, or leave at “Auto” or “None”.
- Notifications: Web Push, MQTT, ntfy, Slack (all independent + optional).
- Review: click Save and finish. Settings write to
/data/localsky.tomlatomically with a snapshot to history.
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 (Mode 2 or 3 below) but is never a dependency. Deep version: docs/standalone.md.
LocalSky has three operating modes. Pick the one that fits your stack.
Mode 1: Standalone (no Home Assistant)
LocalSky talks directly to your irrigation hardware. No HA install required, no HA running, no MQTT broker.
Setup:
- Run the install command above without setting
HA_URLorMQTT_HOST. - In the wizard’s Controllers step, configure your direct-controlled controller (OpenSprinkler is the canonical example).
- 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.
Mode 2: Outbound to HA (LocalSky publishes, HA consumes)
LocalSky talks to your controller directly, AND publishes its state via MQTT discovery so existing HA dashboards see sensor.localsky_* entities automatically. No HA YAML required.
Setup:
- Same install command.
- Configure your controller directly under
/settings/controllers. - Under
/settings/notifications, set the MQTT broker host (your existing HA broker, or any other). - HA auto-discovers
sensor.localsky_<zone>_bucket_mm,sensor.localsky_<zone>_et_today_mm,sensor.localsky_<zone>_planned_seconds,binary_sensor.localsky_zone_<zone>_running, andsensor.localsky_verdict_today.
This mode works well for users who use HA for the rest of their home but want LocalSky to own irrigation.
Mode 3: HA-driven (legacy continuity)
LocalSky’s controller dispatches through HA service calls instead of directly. Useful when you already run Smart Irrigation + Irrigation Unlimited + OpenSprinkler HACS through HA and don’t want to re-plumb.
Setup:
- Pass
HA_URLandHA_LONG_LIVED_TOKENenv vars to the container. - In the wizard’s Controllers step, pick
ha_service_call. Map your LocalSky zones to HA entity ids. - LocalSky reads HA state via
/api/states, dispatches runs via/api/services/<domain>/<service>.
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
}
Add basic auth or oauth2-proxy if you want authentication. LocalSky doesn’t enforce auth itself; the proxy layer is where you add it.
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, $130-180 hardware.
- 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, $130-250 hardware
- DryRun: no-op for testing + demos
LocalSky’s controller HAL is a Rust trait; adding new adapters takes ~100-200 lines. See CONTRIBUTING.md.
Optional: sensors
LocalSky’s engine is fully functional without any sensors beyond the weather sources. Adding sensors unlocks additional logic:
| Sensor type | Unlocks |
|---|---|
| Soil moisture (Ecowitt WH51 / WH52, Aqara, Sonoff) | Per-zone saturation skip, soil-moisture projection, smarter dry-out detection |
| Soil temperature | Soil-frost skip rule (catches the “cold soil + sprinkler = frozen lawn” case better than air temp alone) |
| Rain gauge (separate from weather station) | Improves rain-today accumulation accuracy |
| Lightning detector | Powers the lightning panel + safety skip during active storms |
| Flow meter (on controller) | Validates actual delivered water vs. computed mm depth |
The dashboard renders cleanly without any of these; sensor tiles show empty states with “Connect a sensor to unlock soil-saturation rules” affordances. Once a source provides the data, the tile lights up and additional skip rules activate. The engine never blocks on missing sensor data, weather + ET-based math is the always-on baseline.
Optional: Local LLM
LocalSky’s advisor produces plain-English explanations of why today’s verdict is what it is. Three setup paths from easiest to most flexible:
Ollama
If you have Ollama running on the same host:
ollama pull llama3.2:3b-instruct
LocalSky’s “Auto” provider probes localhost:11434 and detects Ollama within seconds. Models with tool support (llama3.1-8b, qwen2.5-7b) give richer responses on a workstation; phi3-mini-Q4 runs comfortably on a Pi 4.
llama.cpp
Run llama-server on the same host listening on localhost:8080. Auto-detect picks it up the same way.
Any OpenAI-compatible endpoint
Anything that speaks /v1/chat/completions: OpenAI, LM Studio, vLLM, TGI, Anthropic-compatible shims, etc. Set LLM_PROVIDER=openai_compat, LLM_BASE_URL=https://..., LLM_MODEL=..., LLM_API_KEY=....
Troubleshooting
- Dashboard says “no zones”: the wizard hasn’t been run, or the zone editor was skipped. Visit
/setupor/settings/zones. - Verdict shows “(weather rules only; soil rules offline)”: a soil moisture probe isn’t reporting. Check the source under
/settings/sources. - LLM advisor is grayed out: provider is unreachable. Visit
/settings/llm. - MQTT discovery isn’t creating entities in HA: HA’s MQTT integration needs the broker connected (Settings → Devices & Services → MQTT → Configure). Discovery topics live under
homeassistant/<component>/<your-deployment-slug>/.... - Container won’t start on Raspberry Pi: confirm 64-bit OS (
uname -mshould reportaarch64). 32-bit Pi OS is not supported.
Next steps
- docs/standalone.md: full no-HA setup including MQTT-based sensor ingestion
- docs/api.md: REST endpoints + SSE streams for configs and data
- docs/controllers.md: every supported controller in depth
- docs/irrigation-engine.md: FAO-56 math driving verdicts
- docs/grass-species.md: species catalog
- docs/skip-rules.md: every rule in the ladder
- docs/configuration.md: field-by-field config reference
- docs/migration.md: internal Aperture Labs operator path
Standalone Mode (No Home Assistant)
LocalSky is a complete, native irrigation + weather product. Home Assistant is one of several integration paths, not a dependency. This document is for users who:
- Don’t run Home Assistant and don’t want to
- Run HA but want LocalSky to own irrigation end-to-end
- Need to understand exactly what works without HA
What “standalone” gets you (the short answer)
Everything. The full LocalSky feature set runs without HA:
- Live weather dashboard (Tempest UDP / Open-Meteo / Ecowitt / NWS / etc.)
- FAO-56 reference ET₀ with Hargreaves fallback
- Per-zone water balance + MAD-driven scheduling
- 17-rule skip ladder
- 7-day forward verdict strip
- Cycle-and-soak runtime splitting
- Direct controller dispatch (OpenSprinkler HTTP API, ESPHome native protocol, Rachio cloud)
- Sensor ingestion via MQTT subscribe + direct LAN adapters
- LLM advisor (Ollama, llama.cpp, or any OpenAI-compatible)
- Web Push notifications (browser, per device)
- PWA install on iOS + Android
- Settings UI + first-run wizard
What you don’t get without HA:
- HA’s broader home-automation ecosystem (lights, locks, scenes)
- HA’s dashboard widgets and other integrations
That’s a fair trade if you don’t already run HA.
Sensor ingestion without Home Assistant
This is the question that surfaces most often: “I have soil moisture sensors. How do they get into LocalSky without HA?” Three paths, none requiring HA.
Path 1: MQTT broker (the universal path)
Most modern sensors publish to MQTT. LocalSky’s mqtt source subscribes to topics directly. The architecture:
[Sensor: Tasmota / ESPHome / Zigbee2MQTT / etc.]
|
v (publishes to topic)
[MQTT broker: Mosquitto]
|
v (LocalSky subscribes)
[LocalSky source: kind = "mqtt"]
The broker can be Mosquitto (open-source, free, runs in a 5 MB Docker container), EMQX, HiveMQ, or anything that speaks MQTT 3.1.1 or 5.0. HA’s broker works too if you already have one; the point is the broker is the standard, not HA.
Set up Mosquitto
mkdir -p /opt/mosquitto/{config,data,log}
cat > /opt/mosquitto/config/mosquitto.conf <<'EOF'
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
EOF
docker run -d \
--name mosquitto \
--restart unless-stopped \
-p 1883:1883 \
-v /opt/mosquitto/config:/mosquitto/config \
-v /opt/mosquitto/data:/mosquitto/data \
-v /opt/mosquitto/log:/mosquitto/log \
eclipse-mosquitto:latest
Lock this down with username/password before exposing to anything but localhost.
Configure LocalSky to subscribe
In /data/localsky.toml (or via /settings/sources once the editor lands):
[[sources]]
id = "mqtt_sensors"
priority = 80
enabled = true
kind = "mqtt"
[sources.config]
broker_host = "192.0.2.5" # the mosquitto host
broker_port = 1883
username = "${MQTT_USER}"
password = "${MQTT_PASSWORD}"
[[sources.config.subscriptions]]
topic = "tasmota/soil/back_yard/SENSOR"
field = "soil_moisture_pct" # planned WeatherField variant for per-zone soil
json_path = "ANALOG.A0"
zone_slug = "back_yard"
scale = 0.0976 # adjust for sensor calibration
offset = 0.0
[[sources.config.subscriptions]]
topic = "esphome/lawn/temperature/state"
field = "air_temp_f"
# no json_path means parse whole payload as a number
# (ESPHome native API publishes raw values to /state topics)
The adapter handles:
- MQTT 3.1.1 + 5.0
- Wildcards:
+for one segment,#for trailing segments. Example:tasmota/+/SENSORmatches every Tasmota device’s SENSOR topic - Plain numeric payloads (Tasmota / ESPHome /state topics)
- JSON payloads with arbitrary nesting and arrays via
json_path. Examples:"soil.moisture"readsobj["soil"]["moisture"]"sensors.0.value"readsobj["sensors"][0]["value"]
- Tasmota-style number-as-string payloads
- Linear transforms:
published_value * scale + offsetfor unit conversion or sensor calibration
Hardware that works this way
| Device | How it gets to MQTT | LocalSky path |
|---|---|---|
| ESPHome-flashed ESP32 + sensor | Native MQTT publish (or via HA’s MQTT integration) | Subscribe to esphome/<device>/<sensor>/state |
| Tasmota-flashed device | Native MQTT publish | Subscribe to tasmota/<device>/SENSOR |
| Zigbee sensors (Aqara, Sonoff) | Via Zigbee2MQTT (no HA needed) | Subscribe to zigbee2mqtt/<friendly_name> |
| Ecowitt gateway (WH51, WH52) | Via ecowitt2mqtt sidecar | Subscribe to ecowitt/<device_id> |
| Shelly devices | Native MQTT (firmware setting) | Subscribe to shellies/<device>/<field> |
| Arbitrary Arduino / Pi project | PubSubClient / paho-mqtt | Subscribe to whatever topic you publish |
Zigbee2MQTT is a particularly good fit. It’s a single Docker container that talks to a USB Zigbee coordinator (Conbee II, Sonoff dongle, etc.) and publishes every Zigbee device’s state to MQTT. No HA required.
Path 2: Direct LAN adapters
For sensors that speak a documented LAN protocol, LocalSky can talk to them directly without MQTT in the middle.
| Sensor | Adapter | Status |
|---|---|---|
| Tempest hub (UDP broadcast 50222) | tempest_udp | Tested |
| Ecowitt GW1100 / GW2000 (HTTP POST) | ecowitt_local | Planned |
| Ambient Weather (socket.io) | ambient_weather | Planned |
| 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.
Path 3: HTTP webhook receiver (planned)
For sensors with arbitrary HTTP push capability (some commercial weather stations, custom scripts), a generic webhook receiver source is planned:
[[sources]]
id = "webhook_sensors"
kind = "http_webhook"
[sources.config]
path = "/ingest/<token>"
# Sensor POSTs JSON to http://localsky:8090/ingest/<token>
Status: planned. Until then, run a small Python or Node bridge that converts your HTTP source to MQTT publishes.
Controller dispatch without Home Assistant
You have three direct paths:
OpenSprinkler (the recommended hardware)
Direct HTTP API on the LAN. See docs/controllers.md. $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. ~$15-40 in parts. LocalSky’s esphome_native controller (planned) speaks the protobuf protocol directly. Until that adapter lands, run the ESPHome device under ESPHome’s standalone web interface (no HA needed) and use MQTT for state, with manual valve control from LocalSky still pending the native adapter.
Rachio (planned)
Cloud API, no HA required. LocalSky speaks Rachio v1 directly.
Existing setups: what if I already have HA driving Rachio / Hunter / B-hyve?
Use the ha_service_call controller. LocalSky dispatches through your existing HA setup. This is the “legacy continuity” mode; you keep HA in the loop because the integration to your hardware already lives there.
Reaching LocalSky remotely without HA
HA is sometimes used as a remote-access shim. If you’re not running HA, options:
- Tailscale – recommended; works on any platform
- Reverse proxy + TLS – Caddy / nginx / Traefik with Let’s Encrypt
- Cloudflare Tunnel – no port forwarding, no public IP needed
See getting-started.md#remote-reachability.
Notifications without HA
LocalSky has four notification channels, none requiring HA:
- Web Push – per-device, via VAPID. Works in any modern browser
- ntfy.sh – free public service or self-host
- Slack – incoming webhook
- Email (SMTP) – planned
Configure under /settings/notifications. None of these touch HA.
Smart Irrigation? Irrigation Unlimited? Are those needed?
No. LocalSky’s engine is a complete, native replacement for both:
- Smart Irrigation (HACS) does ET₀ + per-zone bucket + Kc + planned-run-seconds. LocalSky’s engine/et0.rs + engine/water_balance.rs + engine/species_catalog.rs do the same thing with the same FAO-56 math.
- Irrigation Unlimited (HACS) does schedule sequencing + zone dispatch. LocalSky’s engine/skip_rules.rs + engine/budget.rs + the controller HAL do the same thing.
The clean-room rewrite was deliberate: both projects are excellent and were the prior art that proved this design space works. LocalSky absorbs their lessons + adds:
- Multi-source weather merge with provenance
- Native ET₀ from station readings (not just forecast model output)
- Cycle-and-soak runoff prevention
- A real first-run wizard
- Settings UI
- Multi-controller HAL
- An LLM advisor
For an existing HA user already running SI + IU: see Mode 3 in getting-started.md. LocalSky’s ha_service_call controller can still dispatch through SI + IU if you’d rather keep your existing setup and use LocalSky for the dashboard + skip-rule engine only.
The “I’m against HA” reality check
Some objections to HA we hear and how LocalSky stands up:
| Objection | LocalSky position |
|---|---|
| “HA is too heavy for one feature” | Agreed. LocalSky is one Docker container, ~30 MB resident. |
| “HA pulls in Python deps I don’t trust” | Agreed. LocalSky is a single Rust binary; no plugin system, no eval’d YAML, no Python at all. |
| “I don’t want a YAML automation layer” | Agreed. LocalSky’s logic is in compiled Rust + a typed TOML config; no automation YAML. |
| “I want a focused single-purpose tool” | Agreed. LocalSky does irrigation + weather. That’s it. |
| “I’m worried HA’s roadmap will diverge from mine” | Agreed. LocalSky is governed by its repo + its license; the project’s scope is irrigation forever. |
| “HA’s UX isn’t great for irrigation” | Agreed. LocalSky’s UI was built for irrigation first, dashboard second. |
LocalSky is for the user who wants the irrigation engine without buying into the broader home automation philosophy. If you’re an HA user already, LocalSky still plays well (Mode 2 / Mode 3); if you’re not, you’re not missing anything.
Future: LocalSky as an HACS integration
There’s a path the other direction: a Python-side HACS integration that polls LocalSky’s REST API and creates HA entities natively, without going through MQTT. This lets HA users get LocalSky as a “first-class HA integration” experience:
- One-click install via HACS
- Configuration through HA’s UI
- LocalSky entities appear under “LocalSky” in HA’s device list
- LocalSky’s verdicts + zone state become available to HA automations and Lovelace dashboards without YAML
Status: roadmap. Once the LocalSky API stabilizes at v1.0, the HACS integration is a separate ~300-line Python project that wraps the REST endpoints documented in docs/api.md. If you’re a Python developer interested in building this, see the CONTRIBUTING.md on cross-project contribution.
Summary table
| Capability | Standalone | HA + LocalSky (Mode 2: outbound) | HA + LocalSky (Mode 3: HA-driven) |
|---|---|---|---|
| Weather dashboard | ✅ | ✅ | ✅ |
| Engine (ET, bucket, skip rules) | ✅ | ✅ | ✅ |
| Controller dispatch | ✅ direct | ✅ direct | ✅ through HA |
| Sensor ingestion | ✅ MQTT subscribe + direct adapters | ✅ same + HA passthrough | ✅ HA passthrough |
| Sensor entities visible in HA | ❌ | ✅ via MQTT discovery | ✅ HA owns them |
| HA automations on LocalSky verdicts | ❌ | ✅ via MQTT entities | ✅ direct in HA |
| Web Push notifications | ✅ | ✅ | ✅ |
| LLM advisor | ✅ | ✅ | ✅ |
| Mobile PWA | ✅ | ✅ | ✅ |
| Configuration surface | LocalSky /settings | LocalSky /settings | LocalSky /settings |
| LocalSky depends on HA | No | No | Yes (for dispatch only) |
Pick the row that matches your current setup and your future direction.
LocalSky as an HACS integration (roadmap)
This document describes a future, separate project: a Python-side HACS integration that exposes LocalSky as a native Home Assistant integration. Distinct from LocalSky itself.
LocalSky’s current relationship with HA has two production paths:
- Mode 2 (outbound): LocalSky publishes via MQTT discovery; HA auto-discovers
sensor.localsky_*entities. Recommended today. - Mode 3 (HA-driven): LocalSky dispatches controller actions through HA’s service-call API. Legacy continuity for users already running HA-driven irrigation.
A third path, HACS integration, would let HA users get LocalSky as a first-class integration installable from the HACS marketplace, without MQTT in the middle.
What HACS is
The Home Assistant Community Store is HA’s marketplace for community-built integrations and custom dashboards. Users install HACS once, then add individual integrations through it. Each integration is a small Python project conforming to HA’s DataUpdateCoordinator + Entity patterns.
What a LocalSky HACS integration would do
The integration polls LocalSky’s REST API and creates HA entities natively. Conceptual flow:
[HA running on user's host]
|
v (HACS-installed Python custom_component)
[LocalSky HACS integration]
|
v (HTTP polls every 30s)
[LocalSky REST API on the same LAN]
|
v
[/api/snapshot, /api/irrigation/snapshot, /api/forecast/snapshot]
The integration would create HA entities matching the MQTT discovery layout LocalSky publishes today:
sensor.localsky_<zone>_bucket_mmsensor.localsky_<zone>_et_today_mmsensor.localsky_<zone>_planned_secondsbinary_sensor.localsky_<zone>_runningsensor.localsky_verdict_todaysensor.localsky_<zone>_soil_moisture(when sensors connected)- One device per LocalSky deployment
Plus actions: service.localsky.run_zone(zone, seconds), service.localsky.stop_zone(zone), service.localsky.stop_all().
Why this path makes sense
The MQTT discovery path (Mode 2) requires:
- A working MQTT broker (operator runs Mosquitto or uses HA’s built-in)
- The HA MQTT integration configured
- The right discovery prefix set in LocalSky
HACS would skip all of that. Click “Add Integration” → “LocalSky” → enter the LocalSky URL → done. HA polls REST; entities appear. No broker, no discovery prefix, no MQTT debugging.
It’s also a place to surface LocalSky-specific affordances that don’t translate cleanly to MQTT entities:
- Run-history Gantt as an HA custom card
- The 7-day verdict strip as a Lovelace UI element
- Native HA service calls that map 1:1 to LocalSky’s REST control endpoints
Project shape
The HACS integration is a separate Python project, in its own repository:
- Suggested name:
homeassistant-localsky - Repository: e.g.
github.com/silenthooligan/homeassistant-localsky - Language: Python 3.11+ (matches HA’s current version)
- License: Apache-2.0 (same as LocalSky)
- Size: ~300-500 LOC of Python (HA integrations are small)
Key files (matches HA’s custom component layout):
custom_components/localsky/
├── __init__.py # entry point, DataUpdateCoordinator setup
├── manifest.json # HA + HACS metadata
├── config_flow.py # UI config flow (host/port/optional API key)
├── const.py # domain name, scan interval, default ports
├── coordinator.py # polls /api/snapshot + /api/irrigation/snapshot
├── sensor.py # sensor.localsky_* entity classes
├── binary_sensor.py # binary_sensor.localsky_* entity classes
├── services.yaml # service definitions for run_zone / stop_zone
├── services.py # service handler implementations
└── strings.json # localized UI strings
hacs.json # HACS-side metadata
README.md # install instructions, screenshots
Why not build it inside LocalSky’s repo?
Three reasons:
- Different release cadence: HA integrations need to track HA’s quarterly version. LocalSky shouldn’t be coupled to that schedule.
- Different runtime: LocalSky is Rust + WASM. The HACS integration is Python. Mixing the two in one repo complicates CI without payoff.
- Different audience: HACS users want the “click install” experience. LocalSky users want “single Docker container.” Splitting repos keeps the two stories crisp.
The HACS integration depends on LocalSky’s REST API (docs/api.md) being stable. Once LocalSky tags v1.0 (API stable), the HACS project becomes a viable side project.
Prerequisites for shipping HACS
Before the HACS project can be useful:
- LocalSky API stabilized at v1.0 with semver guarantees on the JSON wire format
-
/api/healthendpoint reliable for the coordinator’s “is the host up?” check -
/api/irrigation/snapshotschema documented in OpenAPI / JSON Schema (already published at/api/config/schema, planned for the snapshot endpoints too) - Stable controller dispatch via
POST /api/irrigation/action(today; need to verify the wire shape is final)
When those are in place: a Python developer who knows HA’s DataUpdateCoordinator pattern can ship the HACS integration in ~1-2 weekends.
Who builds it?
Not LocalSky’s maintainers. The Rust + agronomy + meteorology surface is enough work for the upstream team. The HACS integration is a perfect community contribution: low-risk Python in a well-trodden HA pattern, with a clear consumer (HA users who want LocalSky native).
If you’d like to build it, see CONTRIBUTING.md on cross-project work and open a discussion on the main LocalSky repo to coordinate.
What about a custom dashboard card?
Separate but related: Lovelace custom cards (also distributed via HACS) for LocalSky-specific UI elements:
localsky-verdict-strip-card: renders the 7-day forward verdict strip in Lovelacelocalsky-zone-card: a single-zone status card with bucket bar + planned-run countdownlocalsky-history-gantt-card: 30-day run history Gantt
These plug into the HACS integration’s entities. Build them as a separate project (hacs-localsky-cards or similar) using lit-html or vanilla web components.
Roadmap relationship
| LocalSky version | HACS dependency status |
|---|---|
| 0.1 | not viable yet (API wire format not stable) |
| 0.2 | API stabilizes; HACS project can start |
| 0.5 | HACS integration alpha, community-tested |
| 1.0 | LocalSky tags 1.0; API semver-locked; HACS integration matures |
See also
- docs/api.md: the REST surface the HACS integration would call
- docs/standalone.md: comparison of all integration modes
- HACS upstream documentation: https://hacs.xyz/docs/publish/start
- Home Assistant custom integration documentation: https://developers.home-assistant.io/docs/creating_component_index
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 to /api/config snapshots the previous version before atomically writing the new one. 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]
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; the migration runner uses it to apply schema changes between releases.
[deployment]
[deployment]
location = { lat = 28.5, lon = -81.4, elevation_m = 30 }
units = "imperial"
timezone = "America/New_York"
display_name = "My Yard"
location.lat/location.lon: required, decimal degreeslocation.elevation_m: optional, used by FAO-56 net-radiationunits:"imperial"(default) or"metric". Per-field overrides live in browser localStorage, not heretimezone: optional IANA name. Null derives from lat/lon at bootdisplay_name: surfaces in the MQTT discovery node_id (slugified) and the dashboard title
[features]
[features]
demo_mode = false
enable_mqtt_publish = true
enable_advisor = true
enable_push = true
nerd_mode_default = false
telemetry = false
All defaults shown. demo_mode swaps every controller for DryRun and uses the synthetic DemoReplay source.
[[sources]]
A list. Each entry has an id, priority, enabled, and a kind discriminator with per-kind config block.
[[sources]]
id = "tempest_lan"
priority = 100
enabled = true
kind = "tempest_udp"
[sources.config]
bind_addr = "0.0.0.0:50222"
hub_serial = null # filter to a specific Tempest hub; null = accept any
Supported kind values: tempest_udp, tempest_ws, open_meteo, ecowitt_local, nws, openweather, pirate_weather, met_norway, ambient_weather, ha_passthrough, demo_replay. See src/config/schema.rs SourceKind enum for per-kind config fields.
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, 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
rain_now_in_hr = 0.01
rain_next_4h_skip_in = 0.10
rain_3day_factor = 1.5
heat_advisory_temp_f = 95.0
heat_advisory_humidity_pct = 60.0
heat_advisory_dry_days = 2
wind_forecast_slack_mph = 5.0
max_wind_mph = 10.0
min_temp_f = 38.0
rain_skip_in = 0.25
frost_skip_soil_f = 35.0
All values match v0.1 hardcoded constants. See skip-rules.md for what each one does.
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
/api/config validates structurally (serde decode) and semantically:
schema_versionmust equal or be less than what the binary supports- Source ids and controller ids must be unique
- Exactly one controller can have
default = true(zero is allowed only when[[controllers]]is empty) - Each zone’s
controller_idmust reference a configured controller latin[-90, 90],lonin[-180, 180]
Bad PUTs return 422 with the specific failure; on-disk file is untouched.
Migration + rollback
On boot, the runner replays any unapplied migrations from schema_migrations. Schema bumps live in src/persistence/migrations/ as numbered SQL files.
Every PUT snapshots the previous config into config_snapshots (M0002) with retention of 20 versions. Roll back via:
POST /api/config/rollback?to=<version>
Always reachable, even when the engine is in a degraded state (no valid controller, no enabled sources). The rollback endpoint never validates the target; if you saved a broken config, you can restore it. Use the safety net responsibly.
Programmatic schema
The JSON Schema is published at runtime: GET /api/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
LocalSky’s persistent state lives in one SQLite file at /data/irrigation.db (mounted via the localsky-data volume in the default compose). It carries:
- 365-day rolling run history (every intended, running, completed, aborted, or skipped irrigation event)
- Verdict history with full input blobs (every skip-check decision is replayable through the current engine)
- Sensor history (when
sensor_historyingestion is enabled) - Config snapshots (the last 20 versions of
localsky.toml) - Web Push subscriptions
The configuration TOML itself lives next to the database at /data/localsky.toml. Together those two files plus the VAPID keypair (typically mounted from the host at /keys/) are the entire persistent state.
Nightly backup
Add a cron or systemd-timer on the host that copies /data/ to off-volume storage. The database is in WAL mode, so a plain file copy while the service is running is safe in practice; for paranoid integrity, take a SQLite-aware snapshot:
# Adjust the source path if you bind-mounted a different directory.
sqlite3 /var/lib/docker/volumes/localsky-data/_data/irrigation.db \
".backup '/backup/localsky/irrigation-$(date +%F).db'"
cp /var/lib/docker/volumes/localsky-data/_data/localsky.toml \
/backup/localsky/localsky-$(date +%F).toml
The VAPID private key, if you generated one, also belongs in the backup. Treat it like a deploy secret.
Restore
Stop the container, replace the files, restart:
docker stop localsky
cp /backup/localsky/irrigation-2026-05-10.db \
/var/lib/docker/volumes/localsky-data/_data/irrigation.db
cp /backup/localsky/localsky-2026-05-10.toml \
/var/lib/docker/volumes/localsky-data/_data/localsky.toml
docker start localsky
LocalSky will replay schema migrations on boot if the restored database is from an older release, so cross-version restores work as long as the gap is within the migration window.
Config-only rollback
If you only need to roll back a misbehaving configuration change (not the run history), use the snapshot endpoint instead:
POST /api/config/rollback?to=<version>
Lists available versions via GET /api/config/snapshots or the Configuration history panel in Settings -> Advanced.
Irrigation Controllers
LocalSky’s IrrigationController port abstracts the act of firing valves. The same engine output (zone X for Y seconds) dispatches to any supported controller. Pick the one that fits your hardware.
Supported controllers
| Controller | Path | Cloud required? | Hardware cost | Status in v0.1 |
|---|---|---|---|---|
| OpenSprinkler (boxed) | Direct HTTP on LAN | No | $130-180 | Tested |
| OpenSprinkler Pi | Direct HTTP on LAN | No | ~$80 (Pi) + relay board | Tested |
| Home Assistant service call | HA REST | No (HA local) | Whatever HA drives | Tested |
| ESPHome sprinkler | ESPHome native API | No | $5-40 ESP32 + valves | Community / planned |
| Rachio Gen 2/3 | Rachio cloud API | Yes | $130-250 | Planned |
| Hunter Hydrawise | Cloud API | Yes | $130-300 | Community / planned |
| B-hyve | Cloud API | Yes | $80-150 | Community / planned |
| DryRun | No-op | No | None | Tested |
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, $180), the canonical choice for an outdoor enclosure.
- OpenSprinkler 3.x bare PCB ($130), DIY mount.
- OpenSprinkler Pi: a Pi HAT + relay board. Cheaper if you have a spare Pi.
- OpenSprinkler OSPi-Plus: newer board, more I/O.
Firmware 2.1.9 or newer is required.
LocalSky integration
[[controllers]]
id = "os_main"
default = true
enabled = true
kind = "opensprinkler_direct"
[controllers.config]
host = "192.0.2.10"
port = 80
password_md5 = "<md5 of plaintext password>"
poll_interval_s = 10
The first-run wizard or /settings/controllers does this for you. The password_md5 is computed client-side at config time; the plaintext never leaves your browser.
What LocalSky uses
GET /jcfor status (zone states, water level %, rain sensor, firmware version)GET /cmfor manual station start/stopGET /cvfor stop-allGET /jlfor run-history backfill
LocalSky never touches the program/schedule storage on the OS device. Schedules live in LocalSky’s engine; the controller is just a valve-firing API.
Where OpenSprinkler shines
- Direct LAN control means no cloud lag, no service outages, no app required
- Detailed status JSON (water level, rain sensor, flow meter, per-station runtime)
- Native run-history endpoint enables LocalSky’s restart-recovery + audit
- Active open-source community
Where OpenSprinkler falls short
- HTTP only (no TLS by default; put it behind a reverse proxy if you must expose it)
- MD5 password (legacy crypto; not a deal-breaker on a LAN but not great)
- 24-station boxed limit (chain a slave for more)
Home Assistant service call (legacy continuity)
If you already drive irrigation through Home Assistant, OpenSprinkler integration, Irrigation Unlimited, Rachio HACS, ESPHome sprinkler, LocalSky can dispatch through HA service calls without replumbing anything.
[[controllers]]
id = "ha_main"
default = true
enabled = true
kind = "ha_service_call"
[controllers.config]
base_url = "http://homeassistant.local:8123"
bearer_token = "${HA_LONG_LIVED_TOKEN}"
start_service = "script.os_zone_toggle"
stop_service = "opensprinkler.stop"
[controllers.config.zone_entity_map]
back_yard = "switch.back_yard_zone"
front_yard = "switch.front_yard_zone"
LocalSky’s payload to HA is normalized: {"entity_id": "<from map>", "duration_s": <seconds>, "minutes": <float>}. Your HA-side script or service template picks the field it understands.
Use cases:
- Migrating from an HA-driven irrigation setup without re-wiring schedules
- Using a controller LocalSky doesn’t have a native adapter for (Hunter, B-hyve via HA), but the HA integration does
- Wanting irrigation runs to flow through HA’s automation engine for additional logic
ESPHome sprinkler (community / planned)
ESPHome’s sprinkler component turns an ESP32 with a relay board into a smart irrigation controller for ~$15-40 total parts cost. The native API (protobuf over TCP) is documented.
[[controllers]]
id = "esp_irrigation"
default = true
kind = "esphome_native"
[controllers.config]
host = "192.0.2.20"
port = 6053
password = "${ESP_API_PASSWORD}"
[controllers.config.zone_entity_map]
back_yard = "switch.back_yard_valve"
front_yard = "switch.front_yard_valve"
Status: trait scaffolded, native adapter implementation deferred. Until then, run the ESPHome device under HA and use the ha_service_call controller. Track progress at the relevant GitHub issue.
Rachio Gen 2/3 (planned)
Rachio is cloud-tethered but well-documented. The v1 API takes a bearer token and exposes zone start, zone stop, schedule query.
[[controllers]]
id = "rachio_main"
default = true
kind = "rachio"
[controllers.config]
api_token = "${RACHIO_API_TOKEN}"
device_id = "..."
[controllers.config.zone_uuid_map]
back_yard = "..." # Rachio zone UUID
Status: schema variant exists, adapter implementation deferred. Until then, drive your Rachio through HA’s Rachio integration and use ha_service_call.
Hunter Hydrawise / B-hyve / others (community)
Both speak cloud APIs that HA integrations exist for. The LocalSky path until native adapters exist: drive them through HA + ha_service_call.
DryRun (no-op)
For testing, demos, and CI. DryRun records intent (with optional simulated runs that write to the SQLite history) but never fires anything.
[[controllers]]
id = "dry"
default = true
kind = "dry_run"
[controllers.config]
simulate_runs = true # write fake completed runs into history for dashboard population
LOCALSKY_DEMO=1 env auto-creates this controller.
Multi-controller setups
The ControllerRegistry supports any number of controllers. Use cases:
- Primary + backup: production OS device + DryRun for safety during config changes
- Geographic split: front-yard OS + back-yard ESPHome on different LAN subnets
- HA-bridged + direct: legacy HA-driven zones + new direct-controlled zones in the same deployment
Per-zone controller_id in ZoneConfig picks which controller fires that zone. Exactly one controller must have default = true; new zones inherit that.
Adding a new controller
Open src/controllers/<name>.rs, implement the IrrigationController trait:
#![allow(unused)]
fn main() {
#[async_trait]
impl IrrigationController for MyController {
fn id(&self) -> &str { &self.id }
fn supports(&self) -> ControllerCaps { ... }
async fn run_zone(&self, slug: &str, duration_s: u32) -> ControllerResult<RunHandle> { ... }
async fn stop_zone(&self, slug: &str) -> ControllerResult<()> { ... }
async fn stop_all(&self) -> ControllerResult<()> { ... }
async fn status(&self) -> ControllerResult<ControllerStatus> { ... }
async fn run_history(&self, since_epoch: i64) -> ControllerResult<Vec<RunRecord>> { ... }
}
}
Add a variant to ControllerKind in src/config/schema.rs. Wire construction in src/runtime.rs::build_controllers. ~100-200 lines total.
See src/controllers/dry_run.rs for the minimal example, src/controllers/opensprinkler_direct.rs for a full HTTP-API integration.
Sensors
LocalSky’s engine produces useful output with just weather and a location. Every sensor you add unlocks more behavior, but nothing is required. The dashboard shows empty states with “connect a sensor to unlock X” affordances where data would otherwise live.
For standalone (no HA) users: the question “how do my sensors get into LocalSky without HA?” has a thorough answer in docs/standalone.md. Short version: run any MQTT broker (Mosquitto is free, 5 MB), point Tasmota / ESPHome / Zigbee2MQTT at it, and LocalSky’s
mqttsource subscribes to the topics you configure. HA never touches it.
Always-on baseline (no sensors required)
Just from weather forecasts + your latitude/longitude, LocalSky computes:
- FAO-56 reference ET₀ (Hargreaves fallback when only temp range is available; Penman-Monteith when wind + solar + humidity show up)
- Crop ET per zone from species-specific Kc curves
- Single-bucket water balance with TAW + MAD-driven scheduling
- 17-rule skip ladder (rain forecast, freeze, wind, already-wet, etc.)
- 7-day verdict strip projection
- Cycle-and-soak runtime splitting
The dashboard renders cleanly with this alone. The verdict tile shows green/yellow/red, the zone cards show planned next-run, the weather panels show forecast data, the radar shows local conditions.
Optional sensors and what they unlock
Soil moisture probes
Examples: Ecowitt WH51 / WH52 (battery), Aqara Zigbee, Sonoff Zigbee, capacitive-soil-moisture sensors on ESPHome.
Unlocks:
- Yard-wide saturation skip rule: when every zone reports moisture at or above its saturation threshold, the engine skips the run.
- Per-zone soil moisture display: a horizontal bar per zone showing current moisture vs. target band.
- Soil-moisture projection: 7-day forward curve under no-irrigation, color-coded for “stays in healthy band” vs. “will dry out”.
- Smarter dry-out detection: catches the case where ET-based math underestimates actual drying (heavy clay holding water visibly longer than expected, or sandy spots draining faster).
Connect via: any source that publishes sensor.<zone_slug>_soil_moisture (HA passthrough), or a direct adapter (Ecowitt LAN, Aqara via HA, Tasmota via MQTT).
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).
Empty states + progressive disclosure
The dashboard uses LocalSky’s <EmptyState/> UI primitive to render tiles for sensor data the operator hasn’t connected. Each empty state:
- Shows the kind of data that would go there
- Names what additional logic the data unlocks
- Links directly to
/settings/sourceswith hints for compatible sources
Example: the soil moisture panel renders as:
🌱 Add soil moisture data Per-zone moisture projection, yard-wide saturation skip, and visible dry-out detection light up when you connect a soil probe. Compatible sources: Ecowitt WH51, Aqara, HA passthrough. [Connect a sensor source →]
Once a source is providing the field, the tile lights up and the skip rules incorporating that field activate automatically. The engine never blocks on missing sensor data; weather + ET-based math is the always-on baseline.
Hardware compatibility matrix
| Sensor | Direct adapter | Via HA | Notes |
|---|---|---|---|
| Tempest hub (UDP) | Tested (v0.1) | Yes | Air temp, humidity, wind, solar, lightning, rain, pressure |
| Ecowitt GW1100/GW2000 LAN | Planned | Yes (ecowitt2mqtt) | All sub-sensors via the gateway |
| Ecowitt WH51/WH52 (soil) | Via gateway | Via ecowitt2mqtt | Battery-powered; 868/915 MHz |
| Aqara Zigbee | Via HA | Yes | Soil moisture + temp probes; needs Zigbee coordinator |
| Sonoff Zigbee | Via HA | Yes | Same as Aqara |
| Ambient Weather | Planned | Yes | Cloud API; socket.io |
| AcuRite tipping bucket | Via Ecowitt or HA | Yes | |
| PurpleAir / AirGradient | Display only | Yes | No engine integration |
| OpenSprinkler flow sensor | Native | Yes | Read via /jc water level field |
Adding a new sensor source
Same shape as adding a weather source. See CONTRIBUTING.md. The WeatherSource trait expects per-tick Observation { source_id, fields: Vec<(WeatherField, f64)> } events; soil moisture is just another WeatherField variant (SoilMoisturePct per zone, planned).
For sensors not in the WeatherField enum (e.g. flow meter readings, ambient pollen), the path is to extend the enum + add a Display-only tile to the dashboard.
“What if I have no sensors at all?”
You’ll get:
- A working weather dashboard with forecast + radar
- An engine that schedules irrigation from ET + soil + species + Kc math
- A 7-day verdict strip
- An LLM advisor (if configured) explaining decisions
You won’t get:
- Soil saturation skip (the engine assumes the bucket model is correct, which it usually is)
- Soil frost skip (covered by air-temp freeze rules)
- Flow-validated runs (the engine trusts that the controller ran the requested duration)
That’s a fully usable setup. Sensors take it from “useful” to “trustworthy”; they’re additive, not gating.
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 the first time a non-empty skip-check verdict is computed each local day.
Four delivery channels can be enabled in parallel: Web Push (browser / PWA), MQTT (Home Assistant or any broker), ntfy.sh, and Slack. None are on by default. The configuration block lives under [notifications] in localsky.toml; see the configuration reference for the field list.
Web Push (recommended)
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.
Web Push needs a VAPID keypair so the push service can verify that the notification is signed by your LocalSky instance. The keypair is generated once and reused for the life of the deployment.
1. Generate the keypair
The web-push Node CLI is the easiest path:
npx -y web-push generate-vapid-keys --json
You’ll get something like:
{
"publicKey": "BNJxRy7...about-87-chars",
"privateKey": "vIVJk0...about-43-chars"
}
The public key is base64url; the private key is the EC private scalar in base64url. Save both somewhere safe before continuing. If you prefer openssl:
openssl ecparam -name prime256v1 -genkey -noout -out vapid-private.pem
openssl ec -in vapid-private.pem -pubout -out vapid-public.pem
# Then base64-url-encode the raw bytes for the env var; web-push CLI is easier.
2. Write the private key as a PEM file
LocalSky reads the private key as a PEM file mounted into the container. With the JSON output from web-push:
mkdir -p ./localsky-keys
cat > ./localsky-keys/vapid-private.pem <<EOF
-----BEGIN PRIVATE KEY-----
$(echo -n "<the privateKey value from the JSON>" | base64 -d | base64)
-----END PRIVATE KEY-----
EOF
chmod 600 ./localsky-keys/vapid-private.pem
(The web-push CLI also has a --pem mode that emits a ready-to-use PEM directly; check npx web-push --help on your installed version.)
3. Configure LocalSky
Either via localsky.toml:
[notifications.web_push]
vapid_public = "BNJxRy7..."
vapid_private_path = "/keys/vapid-private.pem"
vapid_subject = "mailto:[email protected]"
…or via env vars on the container:
environment:
- VAPID_PUBLIC_KEY=BNJxRy7...
- VAPID_PRIVATE_KEY_PATH=/keys/vapid-private.pem
- VAPID_SUBJECT=mailto:[email protected]
volumes:
- ./localsky-keys:/keys:ro
vapid_subject is a contact URI the push service uses if your instance starts misbehaving. Use a real mailto: you actually read.
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.
Troubleshooting
- “Subscribe” button says “Subscriptions are disabled” the server didn’t load a VAPID keypair. Check the container logs for
vapidwarnings; the dispatcher logs a single warning at boot and silently drops every event afterwards if it can’t find keys. - iOS doesn’t 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 won’t ring.
- No notifications after subscribing check
GET /api/v1/push/subscriptionsto confirm the device is registered. If it is, the next zone-start event from the engine will fire one.
MQTT
LocalSky can publish the same three events as MQTT messages to a configured broker. The default discovery prefix is homeassistant, so a Home Assistant install on the same broker auto-discovers sensor.localsky_* and binary_sensor.localsky_zone_*_running entities.
Configure under [notifications.mqtt]. See the HACS integration page for the entity set Home Assistant gets in return.
ntfy.sh
Free, no-account push to phones via the ntfy.sh service or a self-hosted ntfy server. Configure a private topic under [notifications.ntfy] and add the topic to the ntfy app on your phone.
Slack
[notifications.slack].webhook_url accepts an incoming webhook URL. Events post as plain text to the channel the webhook is bound to. Useful for a household ops channel.
What fires when
| Event | Channels | Trigger |
|---|---|---|
| Zone started | Web Push, MQTT, ntfy, Slack | A zone’s running flag flips from off to on |
| Zone stopped | Web Push, MQTT, ntfy, Slack | A zone’s running flag flips from on to off (carries duration in minutes) |
| Daily verdict | Web Push, MQTT, ntfy, Slack | First non-empty skip-check verdict after local midnight |
There is no rate-limit or quiet-hours logic in v0.1. If a misbehaving controller flaps a zone, every device on every channel hears every flap. Track the roadmap for a quiet-hours policy.
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
|
+-- FAO-56 ET0 (eq. 6)
+-- Species Kc (UF/IFAS)
+-- Soil water balance
+-- Skip rules
+-- Cycle-and-soak
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
If multiple sources report the same field, the merge engine picks the winner per merge policy: max for rainfall (one stuck gauge can’t hide actual rain), min for overnight low, highest priority for everything else.
Reference ET₀
LocalSky implements three methods. The Auto path tries them in order and picks the first one whose inputs are present.
1. FAO-56 Penman-Monteith (Allen et al., 1998 eq. 6)
The gold standard. Daily ET₀ over a hypothetical reference grass surface 12 cm tall, well-watered, with albedo 0.23 and a fixed surface resistance of 70 s/m:
ET₀ = (0.408 * Δ * (Rn - G) + γ * (900 / (T+273)) * u₂ * (es - ea))
/ (Δ + γ * (1 + 0.34 * u₂))
Where:
Δ– slope of vapor pressure curve at T_mean (kPa/°C), eq. 13Rn– net radiation (MJ/m²/day), eq. 38 + 39 + 40G– soil heat flux (~0 for daily timescale over grass)γ– psychrometric constant (kPa/°C), eq. 8 = 0.665e-3 × PT– mean daily temperature (°C)u₂– wind at 2m (m/s)es– saturation vapor pressure (kPa), eq. 11 + 12ea– actual vapor pressure (kPa), eq. 14-19 depending on humidity inputs
Rn is the trickiest term. LocalSky uses ASCE-EWRI 2005’s Brunt-form longwave model:
Rs = measured shortwave (or 0.16 * sqrt(Tmax-Tmin) * Ra when missing)
Rns = (1 - 0.23) * Rs # net shortwave with albedo
Rso = (0.75 + 2e-5 * z) * Ra # clear-sky from extraterrestrial
Rnl = σ * ((Tmax+273)^4 + (Tmin+273)^4)/2 * (0.34 - 0.14*sqrt(ea)) *
(1.35 * clamp(Rs/Rso, 0.3, 1.0) - 0.35)
Rn = Rns - Rnl
Ra (extraterrestrial radiation, MJ/m²/day) is computed analytically from latitude and day-of-year via eq. 21, with the sunset hour angle clamped to [-1, 1] so high-latitude polar-day cases don’t NaN.
Implementation: src/engine/et0.rs. Hand-trace tested against eq. 6 for a 50°N April day (Tmax 21.5, Tmin 12.3, RH 84/63, u₂ 2.78, Rs 22.07): ~3.51 mm/day.
2. ASCE-EWRI 2005 short-crop reference ET
Practically identical to FAO-56 for daily computation; the coefficients differ at sub-daily resolution where LocalSky doesn’t operate. Same code path, different et0_method label for operators who want their dashboards to read “ASCE” instead.
3. Hargreaves-Samani 1985
Fallback when wind, solar, or humidity are missing:
ET₀ = 0.0023 * (Ra * 0.408) * (Tmean + 17.8) * sqrt(Tmax - Tmin)
Typical bias vs. PM is +/- 15-25% in humid subtropical climates. Acceptable when better data isn’t available; LocalSky flags Hargreaves-derived values in the dashboard math tile so the operator knows.
Crop ET (ETc)
For each zone:
ETc = ET₀ * Kc(species, DOY) * heat_multiplier(heat_index)
Kc (crop coefficient) is dimensionless, looked up from the species catalog by zone’s grass species and the current day-of-year. The catalog ships 12 species + ornamentals + xeriscape with monthly Kc curves; LocalSky interpolates linearly between mid-month anchors, with Dec/Jan wrap, so the curve is smooth year-over-year. Citations live inline in src/engine/species_catalog.rs.
heat_multiplier is the NOAA Steadman heat index applied as an ET boost from 1.00 at HI <= 85°F up to 1.30 at HI >= 105°F. Captures the empirical observation that 100°F + 70% RH dries a lawn faster than ET₀ alone predicts. Defined in src/engine/skip_rules.rs.
Soil water balance
Per zone, LocalSky tracks one number: depletion_mm, the millimetres of water below field capacity. State evolves daily:
depletion[t+1] = clamp(depletion[t] + ETc - effective_rain - applied_water,
0, TAW)
Where:
effective_rain = gross_rain * capture_efficiency. Default capture efficiency is 0.70 (operator-tunable); accounts for runoff + canopy interception + evaporation losses before water enters the root zone.applied_wateris the depth (mm) of irrigation that reached the soil during this tick.TAW(Total Available Water, mm) =(FC - WP) * root_depth_mm. FC and WP come from the soil texture catalog (USDA classes, sourced from FAO-56 Table 19 and USDA NRCS Part 652).
Trigger to irrigate:
needs_irrigation = (depletion >= RAW)
RAW = TAW * MAD%
MAD (Management Allowed Depletion) defaults per species. St. Augustine: 50%. Bahia: 55%. Ornamental shrubs: 40%. The catalog cites UF/IFAS for the Florida-relevant species.
Implementation: src/engine/water_balance.rs.
Runtime to depth
Once the engine decides to irrigate, runtime in seconds is:
gross_mm_needed = depletion_mm / capture_efficiency
seconds = (gross_mm_needed / precip_rate_mm_hr) * 3600
precip_rate_mm_hr per zone comes from either a measured catch-cup calibration (preferred) or the sprinkler-type default (rotor ~10 mm/hr; spray ~38 mm/hr; MP rotator ~10 mm/hr; drip ~4 mm/hr).
Runtime is capped at max_duration_s so a misconfigured precip rate can’t run a zone for hours.
Cycle-and-soak
If applying the full runtime at the sprinkler’s precipitation rate would exceed the soil’s infiltration capacity, water runs off instead of soaking in. The splitter divides the total runtime into N cycles separated by soak gaps:
if precip_rate > infiltration_rate:
max_cycle_minutes = (infiltration_rate / precip_rate) * 60
N = ceil(total_runtime / max_cycle)
each cycle = total_runtime / N
insert soak_minutes (default 30) between cycles
infiltration_rate comes from the soil catalog, varying by texture and slope (flat / 3-5% / >5% bands per USDA NRCS Part 652 Table 11-3). Sand on flat ground: 50 mm/hr; clay on a steep slope: 3 mm/hr.
Worked example: clay (5 mm/hr infiltration on flat), spray head (15 mm/hr precip), 45-minute total runtime -> 3 cycles of 15 min with two 30-min soaks. Total elapsed wall-clock: 1h 45min. Total water applied: same 45 minutes worth, but it actually enters the root zone instead of running off.
Implementation: src/engine/cycle_soak.rs.
Skip rules
Before any zone fires, the engine runs a deterministic 17-rule ladder. First matching rule wins. Order encodes intent: explicit user overrides > paused > current-conditions safety (raining now, freeze, soil frost, wind) > soil saturation > forecast skips > heat advisory > dry-run > run.
Full enumeration in skip-rules.md. All thresholds are typed config fields in cfg.engine.skip_rules; defaults match v0.1 hardcoded values exactly so upgrading doesn’t change any verdict for unchanged inputs.
Heat advisory pre-water
When the 3-day forecast shows >= 95°F + >= 60% RH and the zone has been dry for >= 2 days, the engine returns verdict run_extended instead of plain run. Dashboard surfaces this; the controller adapter receives 115% of the computed runtime. Empirically gets ahead of the heat stress before it shows in the soil moisture data. Disabled if the 3-day rain forecast covers >= half the operator’s rain-skip threshold.
7-day forward verdict strip
Every dashboard render projects the next 7 days through the same rule ladder, using the daily forecast as synthetic Inputs. The “preview” is the actual decision the engine would make if today were that future day, with the live-only signals (wind_now, rain_intensity_now) zeroed out so they don’t false-fire. Operator gets a glance-able strip showing “skip Tuesday because heavy rain forecast”, “run extended Friday because heat advisory”, etc.
Implementation: src/engine/verdict_strip.rs.
Provenance
Every field in the merged snapshot records source_id, observed_at, and an optional method tag. The dashboard’s math tile reveals “ET₀ 5.2 mm via tempest_lan (penman_monteith)” or “wind 8 mph via open_meteo (forecast)”. Operators always know which input drove which decision; no opaque “the system says so.”
Forecast bias correction
Open-Meteo, NWS, and every other regional forecast source carries systematic bias in any given microclimate. A bowl behind a hill that sees consistent overprediction in summer afternoons doesn’t need the operator to hand-tune their rain-skip threshold every season; LocalSky learns the bias from observed data and folds it out.
How it works
Every refresh, LocalSky records one row per local calendar day in forecast_observations:
| column | source |
|---|---|
predicted_in | The morning’s forecast (forecast.daily[0].precipitation_sum). First write of the day wins. |
observed_in | The day’s end-of-period observed rain from the merged snapshot. Updated as the day accumulates. |
month | 1..12, denormalized so the bias query indexes by month-of-year. |
The first write of the day plants the prediction; the rest of the day refines the observation. Once MIN_OBSERVATIONS (currently 5) days exist in a given month within the rolling 90-day window, the engine computes a per-month bias multiplier:
multiplier = median(observed_in / predicted_in) over the month bucket
multiplier = clamp(multiplier, 0.5, 1.5)
Multiplicative not additive: rain bias is the same shape at 0.2 inch and 2.0 inch. Median not mean: a single 2-inch surprise storm shouldn’t tank the model.
Where it surfaces
- API:
GET /api/v1/forecast/biasreturns the current-month multiplier plus the full 12-month table with sample counts. - Pure module:
engine::forecast_bias::BiasModel::from_observations(observations, today, window)is callable from anywhere; ideal for backtests and replay against historical verdict logs. - Skip rules: v0.1 surfaces the model and persists the observations but does not yet multiply the rain inputs going into the skip ladder. A v0.2 release will wire
corrected_rain = raw_rain * multiplierupstream ofskip_rules::evaluateso the morning verdict reflects the learned bias automatically.
Defaults and bounds
| Constant | Value | Why |
|---|---|---|
MIN_OBSERVATIONS | 5 | Below this, a single outlier dominates. Multiplier stays at 1.0. |
BIAS_FLOOR | 0.5 | Real bias rarely halves a forecast; below this is almost certainly a broken pipeline. |
BIAS_CEIL | 1.5 | Same intuition on the other side. |
DEFAULT_WINDOW_DAYS | 90 | One season. Tracks microclimate shifts without dragging in last year’s summer into this year’s. |
NOISE_FLOOR_IN | 0.02 | Below this in both columns, the day is “dry” and not informative for a multiplicative model. |
Implementation: src/engine/forecast_bias.rs (pure functions + 11 unit tests).
Where to read further
- Grass species catalog: 12 species with monthly Kc curves and citations
- Soil texture catalog: USDA classes with FC, WP, AW, infiltration
- Skip rules: every rule in the ladder with its config knob
- Configuration reference: every
cfg.engine.*field and its default
Skip Rules
LocalSky’s irrigation skip-check is a 17-rule ladder. Every morning (or whenever the engine recomputes), inputs flow through the ladder in order. First matching rule wins. Order matters: explicit overrides beat safety beats current conditions beat forecast beats heat advisory beats dry-run beats run.
Source: src/engine/skip_rules.rs.
Ladder
| # | Rule | Trigger | Threshold | Tunable? |
|---|---|---|---|---|
| 1 | Manual override: skip tomorrow | is_tomorrow && override_tomorrow == "skip" | none | UI |
| 2 | Manual override: run tomorrow | is_tomorrow && override_tomorrow == "run" | none | UI |
| 3 | Vacation pause (timed) | pause_until_epoch > now_epoch | none | UI |
| 4 | Vacation pause (toggle) | is_paused == true | none | UI |
| 5 | Currently raining | rain_intensity_now_in_hr > 0.01 | 0.01 in/hr | rain_now_in_hr |
| 6 | Freeze risk now | temp_now_f < min_temp_f | 38°F | min_temp_f |
| 7 | Overnight freeze | temp_min_24h_f < min_temp_f | 38°F | min_temp_f |
| 8 | Soil frost | soil_temp_yard_min_f < frost_skip_soil_f | 35°F | frost_skip_soil_f |
| 9 | Wind too high now | wind_now_mph > max_wind_mph | 10 mph | max_wind_mph |
| 10 | Windy day forecast | wind_max_today_mph > max_wind_mph + 5 | +5 mph slack | wind_forecast_slack_mph |
| 11 | Already wet | rain_today_in >= 0.05 | 0.05 in | already_wet_in |
| 12 | All zones soil-saturated | every zone’s moisture % >= saturation threshold | per-zone | per-zone soil settings |
| 13 | Rain in next 4 hours | rain_next_4h_in >= 0.10 | 0.10 in | rain_next_4h_skip_in |
| 14 | Tomorrow rain (confidence-weighted) | forecast_in * prob/100 >= rain_skip_in | 0.25 in (weighted) | rain_skip_in |
| 15 | 3-day rain rollup | rain_3day_weighted_in >= 1.5 * rain_skip_in | 1.5x multiplier | rain_3day_factor |
| 16 | Heat advisory (pre-water) | 3-day max >= 95°F + humidity >= 60% + 2+ dry days | composite | heat_advisory_* |
| 17 | Dry-run mode | is_dry_run == true | none | UI |
| - | Default | (no rule matched) | none | run |
Verdict types
The ladder returns one of three verdicts:
skip: don’t irrigate.reasoncarries a human-readable explanation.run: proceed with the engine’s computed runtime.run_extended: proceed at 115% of the engine’s computed runtime. Used only by rule 16 (heat advisory pre-water).
Per-rule details
Currently raining (rule 5)
Live precipitation intensity from the Tempest hub (or merged from any source advertising RainIntensityInHr). 0.01 in/hr 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 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); rotor heads tolerate up to 12-15 mph.
Already wet (rule 11)
Fixed floor at 0.05 in 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(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
rain_now_in_hr = 0.01
rain_next_4h_skip_in = 0.10
rain_3day_factor = 1.5
heat_advisory_temp_f = 95.0
heat_advisory_humidity_pct = 60.0
heat_advisory_dry_days = 2
wind_forecast_slack_mph = 5.0
max_wind_mph = 10.0
min_temp_f = 38.0
rain_skip_in = 0.25
frost_skip_soil_f = 35.0
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.
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 (Florida-centric)
These five are LocalSky’s primary use case. UF/IFAS Extension publication numbers cited.
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
- Notes: most common Florida turf. Shallow-rooted; prefers deeper, less-frequent watering. Active growth Apr-Oct; semi-dormant Nov-Mar in north FL.
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
- Notes: deepest-rooted common turf. 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
- 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
- Notes: drought-tolerant; common Florida pasture grass; 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
- Notes: low-maintenance; iron-chlorotic on high-pH soils.
Cool-season turfgrasses
For northern and transitional-zone users. 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 for winter overseeding in the south.
Non-turf categories
Ornamental shrubs
- Citation: UF/IFAS ENH1115, “Florida-Friendly Landscaping”
- Kc: 0.45-0.55 year-round (low seasonal variation)
- Root zone depth: ~250 mm
- MAD: 40%
- Notes: established shrubs use ~half the ET0 of turf. Water deeply + infrequently. Drip preferred.
Vegetable garden
- Kc: 0.55 / 0.65 / 0.75 / 0.90 / 1.10 / 1.15 / 1.15 / 1.05 / 0.90 / 0.75 / 0.65 / 0.55
- Root zone depth: ~400 mm
- MAD: 45%
- Notes: critical at germination and fruit set. Mulch heavily to cut ET. Curve drawn from FAO-56 Table 12 (vegetables mid-season).
Drip xeriscape
- Kc: 0.25-0.35 year-round
- Root zone depth: ~300 mm
- MAD: 30%
- Notes: established native plantings on drip. Water only during establishment / drought stress.
Other / unknown
- Kc: 0.70 flat
- Root zone depth: 150 mm
- MAD: 50%
- Notes: generic placeholder. Override per zone with measured values.
How LocalSky uses these
The catalog drives three things:
- ETc per zone per day:
ET0 * Kc(species, day-of-year). Day-of-year interpolates linearly between mid-month anchor points with Dec/Jan wrap, so the curve is smooth across new year. - Default root zone depth: feeds TAW (Total Available Water) computation, which together with MAD sets the irrigation trigger threshold. Operators can override via
ZoneConfig.root_depth_mm. - Default MAD: sets how dry the soil gets before LocalSky recommends watering. Override via
ZoneConfig.mad_pct_override.
Contributing a species
New species PRs welcome. Open a PR against src/engine/species_catalog.rs with:
- 12 monthly Kc values (mid-month anchors)
- Default root zone depth (mm)
- Default MAD percentage
- A citation: FAO-56 Table 12, an Extension publication number, 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. LocalSky uses field capacity (FC), wilting point (WP), available water (AW = FC - WP), and infiltration rate per texture + slope. Source: src/engine/soil_catalog.rs.
Pick texture per zone in the zone editor. If unsure, use the USDA texture triangle: rub moist soil between your fingers and match to the closest class.
Catalog
Values per FAO-56 Table 19 + USDA NRCS Part 652 Table 11-3.
| Texture | FC (m³/m³) | WP (m³/m³) | AW (mm/m) | Infil flat (mm/hr) | Infil 3-5% (mm/hr) | Infil >5% (mm/hr) |
|---|---|---|---|---|---|---|
| Sand | 0.09 | 0.03 | 60 | 50 | 35 | 25 |
| Loamy sand | 0.14 | 0.06 | 80 | 35 | 25 | 18 |
| Sandy loam | 0.23 | 0.10 | 130 | 25 | 18 | 12 |
| Loam | 0.34 | 0.12 | 220 | 13 | 10 | 7 |
| Silt loam | 0.32 | 0.15 | 170 | 10 | 8 | 5 |
| Clay loam | 0.39 | 0.20 | 190 | 8 | 6 | 4 |
| Clay | 0.42 | 0.25 | 170 | 5 | 4 | 3 |
How the values map into the engine
Total Available Water (TAW)
TAW_mm = (FC - WP) * root_depth_mm
This is the depth of water the zone can hold between field capacity (fully wet, no gravity drainage) and the wilting point (so dry the plant gives up). St. Augustine on sandy loam at the default 150 mm root depth: TAW = (0.23 - 0.10) * 150 = 19.5 mm. Same species on loam at the same depth: TAW = (0.34 - 0.12) * 150 = 33 mm, more than 1.6x the buffer.
Readily Available Water (RAW)
RAW_mm = TAW_mm * MAD_pct
MAD (Management Allowed Depletion) comes from the species catalog. RAW is the depletion threshold beyond which the plant starts to stress. LocalSky’s irrigation trigger is depletion >= RAW.
St. Augustine on sandy loam with default 50% MAD: RAW = 19.5 * 0.50 = 9.75 mm. The engine triggers irrigation when the bucket dips below ~10 mm of depletion.
Infiltration rate
Determines whether cycle-and-soak is needed. The three slope bands per row reflect that water runs off faster on a hillside than on a level patch. The cycle-and-soak splitter divides total runtime when the sprinkler’s precipitation rate exceeds infiltration.
Example: spray head (15 mm/hr precip) on clay flat (5 mm/hr infiltration). Each minute of runtime delivers 15/60 = 0.25 mm but the soil can only absorb 5/60 = 0.083 mm. Cycling 1 minute on, 4 minutes “soak” wouldn’t actually work because evaporation losses kick in. LocalSky’s default minimum cycle is 3 minutes; soak gap is 30 minutes; the splitter computes the maximum continuous on-time at ~(infiltration/precip) * 60 minutes.
Picking the right texture for your zone
Without a soil test, two practical methods:
Ribbon test
- Take a handful of moist (not wet) soil. Squeeze into a ball.
- Squeeze the ball through your thumb and forefinger to form a ribbon.
- Categorize:
- No ribbon, falls apart: sand or loamy sand
- Weak ribbon (<2.5 cm before breaking): sandy loam or loam
- Medium ribbon (2.5-5 cm): clay loam or silt loam
- Strong ribbon (>5 cm): clay
Jar test
- Half-fill a quart jar with soil from the zone’s root depth.
- Fill the rest with water + a teaspoon of dish soap.
- Shake hard. Set aside.
- After 1 minute, mark the sand layer (settles first).
- After 2 hours, mark the silt layer.
- After 24-48 hours, mark the clay layer (or what hasn’t settled yet).
- Use the USDA triangle to classify based on relative thicknesses.
When in doubt
If you genuinely don’t know, sandy loam is the safest “Florida default” guess. It’s the median Florida turf soil and the engine’s math is most forgiving when off by one texture class in either direction (loamy sand or loam).
Contributing a texture
The catalog is a fixed enumeration (USDA’s classification is the standard; “soil 1” and “soil 2” aren’t textures). New entries are not expected. If you need finer-grained soil characterization, override per zone via direct FC/WP/AW values in a future iteration’s ZoneConfig.soil_overrides block.
Further reading
- USDA NRCS National Soil Survey Handbook
- FAO Irrigation and Drainage Paper No. 56, Chapter 8 (ETc - Single Crop Coefficient)
- USDA NRCS Part 652 National Irrigation Guide, Chapter 11 (Sprinkler Irrigation)
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.
Versioning
The /api/v1 namespace is the stable contract. Every path documented below is reachable at both /api/v1/<path> and /api/<path> until further notice. 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 (field rename, type change, field removal, even a default-value swap) fails CI on cargo test until a maintainer runs cargo insta review and acknowledges the diff. That acknowledgement is the moment to bump api_version: minor on additive changes, major on breaking.
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.
{
"service": "localsky",
"service_version": "0.2.0-alpha.1",
"api_version": "1.0.0",
"api_prefix": "/api/v1",
"license": "Apache-2.0",
"repository": "https://github.com/silenthooligan/localsky"
}
Authentication
LocalSky doesn’t enforce auth at the application layer. If you need it, front the service with a reverse proxy (Caddy basic auth, nginx + oauth2-proxy, Cloudflare Access, Tailscale ACLs). The proxy is the right boundary because LocalSky shouldn’t store user credentials.
CORS is locked to same-origin by default. Edit /settings/advanced to whitelist additional origins if you need cross-origin access.
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.
GET /api/v1/snapshot
Current Tempest weather snapshot. Returns the merged live observation set: air temp, humidity, wind, solar, lightning, rain.
{
"air_temp_f": 87.2,
"humidity_pct": 65.0,
"wind_mph": 4.5,
"wind_gust_mph": 8.1,
"wind_bearing_deg": 218,
"solar_w_m2": 712.3,
"uv_index": 7.5,
"pressure_in_hg": 30.05,
"rain_today_in": 0.00,
"rain_intensity_in_hr": 0.00,
"lightning_count_last_3h": 0,
"battery_volts": 2.78,
"observed_at_epoch": 1700000000
}
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);
// ...
});
Keep-alive every 15 seconds.
GET /api/v1/irrigation/snapshot
Current irrigation state: per-zone bucket / running status / last-run / planned next-run, plus the merged verdict and the 7-day forward strip.
{
"ha_reachable": true,
"verdict": "run",
"reason": "",
"zones": [
{
"name": "Back Yard",
"slug": "back_yard",
"running": false,
"bucket_mm": -12.3,
"planned_run_seconds": 1200,
"last_run_epoch": 1700000000,
"math": { ... }
}
],
"skip_check": { ... },
"forecast": { ... },
"seven_day_verdicts": [ ... ],
"soil_forecasts": [ ... ],
"water_budgets": [ ... ]
}
GET /api/v1/irrigation/stream
SSE feed for irrigation state. Same event shape 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.
Configuration endpoints
Only mounted when LOCALSKY_V2=1 is set or /data/localsky.toml exists.
GET /api/v1/config
Current Config as JSON. Secrets are not redacted from the JSON wire today (the trade-off documented in SECURITY.md); treat the endpoint as you would the on-disk TOML.
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. Server:
- Validates structurally (serde decode)
- Validates semantically (unique ids, exactly one default controller, lat/lon in range, etc.)
- Snapshots the previous config into
config_snapshots(retention 20) - Atomically writes
/data/localsky.toml(write to .tmp, fsync, rename) - Notifies the runtime via the broadcast bus so hot-reload kicks in
Returns 200 + the new ConfigVersion on success; 422 + structured error on validation failure (on-disk file untouched).
curl -X PUT http://localhost:8090/api/v1/config \
-H 'Content-Type: application/json' \
-d @new-config.json
POST /api/v1/config/preview
Dry-run validation. Body: { "candidate": <Config JSON> }. Server runs the same validation pipeline as PUT but returns the result without writing.
{
"ok": true,
"errors": []
}
Useful for client-side “validate before save” flows.
POST /api/v1/config/rollback?to=<version>
Restore a previous snapshot. The endpoint is always reachable even when the engine is in a degraded state (no enabled sources, no default controller). Use it to recover from a bad config push.
curl -X POST 'http://localhost:8090/api/v1/config/rollback?to=12'
Returns 200 + the restored Config on success; 404 if the version doesn’t exist.
Wizard endpoints
Used during first-run; mounted only when no /data/localsky.toml exists (or LOCALSKY_V2=1 overrides).
GET /api/v1/wizard/draft
Current draft, or a fresh default if none exists. Returns:
{
"current_step": "welcome",
"config": { ... },
"license_accepted": false,
"telemetry_choice": null,
"last_updated_epoch": 1700000000
}
PUT /api/v1/wizard/draft
Save the draft. Body: a full WizardDraft object. Server writes atomically.
DELETE /api/v1/wizard/draft
Clear the draft (cancel + restart the wizard).
POST /api/v1/wizard/apply
Finalize: validate the draft, write /data/localsky.toml via the FileConfigStore, drop the draft. The runtime’s setup-gate middleware re-mounts normal routes after this returns.
Returns 200 + ConfigVersion on success; 422 + WizardError otherwise.
POST /api/v1/wizard/test_source
Body: { "source": <SourceEntry> }. Attempts a connect + read against the given source. Returns capability + reachability report.
(Stubbed in v0.1; full implementation lands alongside the per-kind adapter in a follow-up.)
POST /api/v1/wizard/test_controller
Body: { "controller": <ControllerEntry> }. Attempts a connect + status read against the given controller.
POST /api/v1/wizard/scan_zones
Body: { "controller": <ControllerEntry> }. For controllers that support discovery (OpenSprinkler, ESPHome), returns the list of detected zones so the UI can pre-populate the zone editor.
GET /api/v1/wizard/geocode?q=<address>
Server-side proxy to Nominatim with the required User-Agent. Returns up to 5 candidates:
[
{
"display_name": "Orlando, Florida, USA",
"lat": "28.5383",
"lon": "-81.3792"
}
]
Irrigation control endpoints
POST /api/v1/irrigation/action
Dispatch a controller action. Body shape varies by kind:
{ "kind": "run", "zone": "back_yard", "seconds": 600 }
{ "kind": "stop", "zone": "back_yard" }
{ "kind": "stop_all" }
{ "kind": "run_now" }
{ "kind": "set_threshold", "name": "max_wind_mph", "value": 12.0 }
{ "kind": "set_paused", "value": true }
Server clamps zone runs to max_duration_s (default 7200). Returns 200 on success, 422 with the controller’s error otherwise.
GET /api/v1/irrigation/history?from=<epoch>&to=<epoch>
Run history window. Returns up to 1000 rows ordered by start_epoch ASC.
{
"from_epoch": 1699913600,
"to_epoch": 1700000000,
"runs": [
{ "zone_slug": "back_yard", "start_epoch": 1699920000, "duration_s": 600, "skip_reason": null, "status": "completed" }
]
}
GET /api/v1/irrigation/explanation
Latest LLM-generated plain-English explanation of today’s verdict. Cache TTL 5 minutes.
GET /api/v1/irrigation/anomalies
Latest LLM-generated anomaly list. Cache TTL 1 hour. Returns:
{
"anomalies": [
{
"severity": "warn",
"type": "soil_moisture_drift",
"description": "Back yard moisture has dropped 18% in 24h, faster than ETc alone predicts."
}
]
}
Web Push endpoints
GET /api/v1/push/vapid-key
Public VAPID key for browser subscription. Returns the key as a base64url string, or 503 if push is not configured.
POST /api/v1/push/subscribe
Body: PushSubscription JSON from the browser’s pushManager.subscribe(). Server stores it.
POST /api/v1/push/unsubscribe
Body: { "endpoint": "..." }. Removes the row.
Health + meta
GET /api/v1/health
Liveness + readiness. Returns:
{
"status": "ok",
"config_present": true,
"version": "0.2.0-alpha.1",
"sources_reachable": 2,
"controllers_reachable": 1,
"uptime_s": 1234
}
When config_present is false the server is in wizard mode; the dashboard redirects to /setup.
Service worker + PWA
GET /sw.js
Service worker script. Version interpolated server-side from CARGO_PKG_VERSION so every deploy bumps the SW version.
GET /manifest.webmanifest
PWA manifest. Static.
Client tooling
A minimal Python client to round-trip the config:
import requests, json
base = 'http://localhost:8090'
cfg = requests.get(f'{base}/api/v1/config').json()
# tweak something
cfg['engine']['skip_rules']['max_wind_mph'] = 12.0
r = requests.put(f'{base}/api/v1/config', json=cfg)
if r.status_code == 200:
print('saved version', r.json()['version'])
else:
print('rejected:', r.json())
JavaScript / shell / Rust clients follow the same shape.
UX Journey: install, upgrade, change
This document audits LocalSky’s operator experience across every state transition: first install, version upgrades, configuration changes, hardware changes, recovery from misconfiguration, and migration between modes. Each section names the gaps + how LocalSky handles them.
First-run install
What the operator sees
docker runreturns. Container is up.- Visit
http://<host>:8090. Server detects no/data/localsky.toml. Redirects to/setup/welcome. - Eight-step wizard (docs/getting-started.md). Each step’s “Save and finish later” link writes a draft to
/data/localsky.toml.draft. The wizard is resumable across restarts. - Step 8 (Review) presents the final summary + a single “Save and finish” button. POSTs
/api/wizard/apply:- Validates the draft
- Writes
/data/localsky.tomlatomically (write to .tmp, fsync, rename) - Records a snapshot in
config_snapshots(version 1) - Deletes the draft file
- Server re-mounts normal routes. Dashboard appears at
/.
Gaps + how LocalSky handles them
| Gap | Handling |
|---|---|
| Browser refresh mid-wizard | Draft is persisted server-side after each step; refresh resumes at the same step with same values |
| Container restart mid-wizard | Same: draft survives restart |
| User closes tab and comes back days later | Draft still there. The wizard banner on the dashboard (“Resume setup”) invites resumption |
| Wizard finishes but config validation fails | Apply returns 422 with the specific field error inline; on-disk file untouched; draft preserved |
| First boot has no location entered | Lat/lon default to (0.0, 0.0); validation flags “null island”; user can’t advance past the Location step until corrected |
| User doesn’t accept the license | Apply refuses with LicenseNotAccepted error |
What still needs work
- Geocode helper from address text: server-side proxy to Nominatim exists at
/api/wizard/geocode, but the wizard UI’s Location step currently shows raw lat/lon inputs only. The address-to-lat/lon flow is plumbed but not wired into the form yet. Tracked. - Map picker for location: Leaflet is already loaded for the radar panel. The wizard could re-use it as a click-to-pick interface. Planned.
- Test buttons in Sources / Controllers / LLM steps: the API endpoints exist (
POST /api/wizard/test_sourceetc.) but return 501 in v0.1; the per-adapter test logic lands as each adapter graduates from planned to tested.
Upgrades
What the operator does
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 \
-e LOCALSKY_V2=1 \
ghcr.io/silenthooligan/localsky:latest
Or for users on :latest-pinned compose files, docker compose pull && docker compose up -d. Watchtower / Diun / Renovate all work.
What happens inside
- Container starts. Reads
/data/localsky.toml.schema_versionfield decides what migration path applies. - Migration runner (src/persistence/runner.rs) opens the SQLite DB. Applies any new migrations in order (one per release that needed schema changes). Records each in the
schema_migrationstable; idempotent if you rerun. - Config loader checks
cfg.schema_version. If lower thanCURRENT_SCHEMA_VERSION(currently 1), runs the registered migration chain (one function per version bump). Writes the migrated config back. - Runtime composition root constructs registries from the (now-current) Config. If any source/controller type has been removed in the new release, those entries are skipped with a warn log.
- Boot completes. Dashboard at the same URL, same data, same zones.
Gaps + how LocalSky handles them
| Gap | Handling |
|---|---|
| New config schema version released | config/migrate.rs runs the v-to-v+1 chain. Operator does nothing |
| New DB schema version released | persistence/runner.rs applies the new SQL migrations idempotently |
| Operator skips multiple versions | Migrations are chained; v0.2 → v0.5 runs M0006, M0007, … M0012 in order |
| New release adds a required config field | Old configs missing the field are accepted: serde fills with the default declared in the schema |
| New release removes a deprecated field | Serde with #[serde(default)] on every field means missing-from-disk is fine; extra-on-disk is silently ignored. The next save drops the deprecated field |
| Operator downgrades to a previous version | If the persisted config has schema_version > CURRENT_SCHEMA_VERSION of the binary, the loader refuses with SchemaTooNew { found, known }. Operator must either re-upgrade or restore an older snapshot via POST /api/config/rollback |
| Migration partially succeeds | Each migration runs in a single transaction. Partial application is impossible: rusqlite commits or rolls back |
| DB lock from prior process | Idempotent: rerunning boot picks up where the previous attempt left off |
What still needs work
- CHANGELOG breaking-change section is required for every release that bumps
CURRENT_SCHEMA_VERSION. Currently part of the release-process docs but not yet enforced by CI. - Automatic snapshot before migration: today, schema migrations apply directly. A pre-migration snapshot would let operators roll back a botched migration. Planned.
Configuration changes
What the operator does
Edit a value in /settings/<section>. Click Save. Settings UI POSTs the change to /api/config (or PATCH-style mutates one section + PUTs the whole config back, depending on the page).
What happens inside
- Server validates the incoming Config (serde decode + semantic validation).
- If valid: snapshot the previous Config into
config_snapshots(retention 20), write the new TOML atomically. - Broadcast a
ConfigEventover the runtime’stokio::sync::broadcastchannel. Interested subsystems re-read their slice:ZoneUpdated(slug)→ engine picks up new Kc/soil/MAD on next tickSourceAdded(id)→ registry spawns a new adapter taskControllerChanged(id)→ registry swaps the pointer atomically; in-flight runs complete on the old controllerLlmChanged(...)→ advisor reconfigures on next call (cache TTL respects the new config)
- If invalid: return 422 with structured field-by-field errors. On-disk file untouched.
Gaps + how LocalSky handles them
| Gap | Handling |
|---|---|
| User saves an invalid config | 422 + inline field errors; settings UI surfaces each next to the relevant field |
| Two users edit simultaneously | Last write wins (no optimistic-locking version on PUT today); both writes get snapshotted so neither is lost from history |
| User removes a source another zone depends on | Validator rejects: “zone X references source Y which is not configured” |
| User removes the default controller | Validator rejects: “at least one controller must have default = true” |
| User pushes a config that crashes the engine | Engine errors are caught at the tick boundary; the previous tick’s snapshot stays in place until the next valid one. UI shows a yellow “engine error: …” banner |
| User wants to undo a config change | POST /api/config/rollback?to=<version> restores any snapshot from the last 20. Reachable even when the engine is degraded |
| Operator wants to script a change | /api/config GET → mutate → PUT roundtrip works from curl / Python / shell |
What still needs work
- Optimistic locking on PUT: a
versionfield in the Config wire format would let the server reject stale writes from simultaneous edits. Today it’s last-write-wins. - Hot-reload broadcast: the broadcast channel is plumbed but not yet wired to every subsystem. Currently sources + controllers re-read; engine tick re-reads. LLM advisor doesn’t yet (next call picks up).
- Per-section dirty-state UX: the settings UI saves the entire Config block on each PUT. A per-section PATCH endpoint would let only-this-section pages save without touching unrelated fields. Not blocking but would tighten the UX.
Hardware changes
Adding a new sensor
The flow:
- Connect the sensor’s physical hardware (battery, Zigbee pair, network plug).
- Decide the path: direct LAN adapter (e.g. Tempest), MQTT subscribe (Tasmota / ESPHome / Zigbee2MQTT), Ecowitt POST receiver, or HTTP webhook (docs/standalone.md).
- Add a source entry under
/settings/sources. Optionally test the connection via the “Test” button. - Save. The Runtime spawns a new source task; observations start flowing immediately.
- If the sensor is per-zone (soil moisture, soil temp), reference it from the zone editor:
ZoneConfig.soil_sensor_id.
Swapping controllers
The flow:
- Add the new controller under
/settings/controllersalongside the old one. Test connection. - Per-zone: update
ZoneConfig.controller_idto point to the new one. Save. - When confident, mark the new controller as
default = true(the validator enforces exactly one default). - Optionally delete the old controller entry. Or leave it as a standby.
Zero downtime: in-flight runs complete on whichever controller they started on. New runs dispatch through the new default.
Swapping weather sources
Same pattern: add the new source, configure it, mark it preferred by setting a higher priority, test, then remove the old source if desired. The merge engine handles overlap automatically: the source with the highest priority for each WeatherField wins.
Replacing physical hardware (e.g. dead Tempest, new Ecowitt)
- Add the new source first. Confirm data is flowing in the live dashboard (the merge engine shows provenance per field; you’ll see “via ecowitt_new”).
- Remove the dead source. Verify no field reverts to a stale value.
- If you have run history attributed to the old source ID, leave the entry disabled instead of deleting to preserve attribution.
Adding a new zone
/settings/zones→ Add.- Species + soil texture + sprinkler PR + controller mapping.
- Save. Engine starts tracking the bucket from “fully wet” assumption (depletion_mm = 0); the operator can adjust by observation.
Removing a zone
/settings/zones→ delete row.- Confirm. The zone disappears from the dashboard immediately. Run history for the zone is preserved (the runs table just stops getting new entries for that slug).
Gaps + how LocalSky handles them
| Gap | Handling |
|---|---|
| Sensor disconnects unexpectedly | Source-side: the merge engine detects “field not seen in N minutes” and demotes that source for that field. Dashboard shows the older value with a “stale” badge |
| Controller goes offline | Status badge flips red. Manual zone-run buttons return “controller offline” error. Scheduled runs queue with status='intended' and dispatch when the controller returns |
| Operator pulls the SD card mid-run | Restart picks up runs.status='running' rows older than 5 min, polls the controller, marks aborted with the actual end-time from controller telemetry (when supported) |
| Wrong source supplies a field | Lower the priority or disable. Merge engine picks the next source down. Provenance display reveals which source is currently winning each field |
| Operator wants to test a config change non-destructively | POST /api/config/preview runs validation against a candidate config without writing. Settings UI plans to expose this as a “Validate” button |
What still needs work
- Sensor disconnect detection isn’t surfaced explicitly in the UI today. Per-source last-seen timestamps are stored in
sensor_history; a panel exposing them is planned. - Mid-run controller swap protection: today, deleting the controller a zone references will validate-reject. But a less-obvious case is changing the controller_id on a zone while the zone is running. The right behavior is to refuse the change until the run completes; today’s validator allows it.
- Zone delete confirmation: the settings UI plans a “delete this zone?” modal with a “downloads run history first” affordance. Planned.
Mode migration (e.g. standalone → outbound HA → HA-driven)
LocalSky’s three modes are runtime-switchable:
- Standalone → outbound HA (Mode 2): set
notifications.mqtt.host. LocalSky starts publishing discovery topics. No other change. HA auto-creates entities. - Standalone → HA-driven (Mode 3): add a
kind = "ha_service_call"controller; mark it default. Zone runs now dispatch through HA. - HA-driven → standalone: add a direct-control controller (e.g.
opensprinkler_direct); mark it default. Optionally delete the HA controller.
In all cases, zone runs in flight complete on whichever controller started them. Configuration changes flow through the same hot-reload mechanism as other config edits.
Gaps
| Gap | Handling |
|---|---|
| Mode transitions are step-wise; no “convert mode 1 to mode 2” wizard | Documented as a checklist; auto-converter is planned |
| HA passthrough → MQTT subscribe migration | Both work simultaneously; the merge engine prefers higher-priority sources. Operator removes the old one once confidence is established |
Recovery patterns
“I broke my config and now nothing loads”
docker exec -it localsky cat /data/localsky.toml > /tmp/config-broken.toml
# Edit /tmp/config-broken.toml to fix the issue
docker exec -i localsky tee /data/localsky.toml < /tmp/config-broken.toml
docker restart localsky
Or via the API: POST /api/config/rollback?to=<previous_version>. The rollback endpoint is always reachable even when the engine is degraded.
“My data dir is corrupted”
LocalSky’s SQLite uses WAL mode + synchronous=NORMAL. Crashes mid-write produce a rolled-back state; the next boot recovers.
If the DB file is irrecoverable (filesystem-level corruption):
docker stop localsky
mv /opt/localsky/data/irrigation.db /opt/localsky/data/irrigation.db.bak
docker start localsky
The migration runner re-creates a fresh DB. Config + zones + sources + controllers are preserved (those live in localsky.toml). Run history is lost; the new DB starts fresh.
“I want to migrate to a new host”
# Old host
docker stop localsky
tar czf localsky-backup.tar.gz -C /opt/localsky data
# New host
scp localsky-backup.tar.gz newhost:
ssh newhost
mkdir -p /opt/localsky
tar xzf ~/localsky-backup.tar.gz -C /opt/localsky
docker run -d \
--name localsky \
--restart unless-stopped \
-p 8090:8090 \
-v /opt/localsky/data:/data \
-e LOCALSKY_V2=1 \
ghcr.io/silenthooligan/localsky:latest
Done. All state (config, zones, runs history, push subscriptions, sensor history) is in /data.
“I want to clone production to a staging instance”
Same as host migration but pointed at a different host + port. The two instances can publish to different MQTT topic namespaces (set deployment.display_name differently); both can read from the same HA broker / Tempest hub without conflict.
Summary: what to look at before publishing
The UX gaps surfaced by this audit, in priority order:
- Wire address-to-lat/lon geocoding into the Location wizard step (server endpoint exists; UI input doesn’t call it yet)
- Test buttons in Sources / Controllers / LLM steps (501 → real)
- Sensor list editor in
/settings/sources(with add/remove/test affordances) - Controller list editor in
/settings/controllers(with scan-zones + test-fire) - Zone list editor in
/settings/zones(with species picker that uses the grass-species images + calibration modal for catch-cup measurement) - Optimistic-locking version on PUT /api/config (so simultaneous edits don’t silently overwrite)
- Pre-migration snapshot (one extra config_snapshots row when schema_version bumps; gives clean rollback even for schema migrations)
- Sensor last-seen panel (per-source freshness display)
- Zone delete confirmation modal (with run-history download)
- Map picker for the Location step (Leaflet click-to-pick)
None of these are launch-blocking; the wizard + settings paths all work end-to-end today. They’re polish items between v0.1 and v0.2.
Cross-references
- docs/getting-started.md: the conversational first-run walkthrough
- docs/configuration.md: field-by-field config reference
- docs/api.md: REST endpoints used by every operation in this doc
- docs/migration.md: operator playbook for moving from internal to public deployment
- docs/standalone.md: full no-HA path including hardware change scenarios
Migrating LocalSky to a public GitHub repo
This document walks through the steps to take the internal aperturelabs/aperturelabs-localsky Gitea repo and publish a sanitized version at silenthooligan/localsky on GitHub. Follow in order; do not skip the rotation step.
Step 0: Secret rotation (do this first)
The internal repo has .env tracked with live tokens. Before publishing anything, treat the existing token set as leaked and rotate:
- In Home Assistant: revoke
HA_LONG_LIVED_TOKENand mint a fresh one. Update the internal.env. - Regenerate the VAPID keypair:
Updateopenssl ecparam -name prime256v1 -genkey -noout -out vapid-private.pem openssl ec -in vapid-private.pem -pubout -outform DER 2>/dev/null \ | tail -c 65 | base64 -w0 | tr '+/' '-_' | tr -d '='VAPID_PUBLIC_KEYin.envand replace the file at_shared/keys/vapid-private.pem. Re-subscribe any active devices. - Mint a new fine-grained GitHub PAT scoped to
silenthooligan/localskyonly withcontents: write,packages: write,actions: write. Stash in the private overlay’s.envasGHCR_PAT. Do NOT reuse the existing PAT documented inaperturelabs-core-manager/secrets.md.
Step 1: Create the public repo
On GitHub, create silenthooligan/localsky empty + private. We flip to public only after the sanitization checks pass.
Step 2: Snapshot the working tree
mkdir -p /tmp/localsky-public-seed
git -C /home/erik/Development/aperturelabs/aperturelabs-localsky archive HEAD \
| tar -x -C /tmp/localsky-public-seed
cd /tmp/localsky-public-seed
Remove tracking artifacts that should not be in the public repo:
rm -rf .git .gitea data _shared keys
rm -f .env *.pem
Step 3: Sanitize
TL;DR: run the bundled script.
cd /tmp/localsky-public-seed ./scripts/sanitize-for-public.shThe script handles steps 3a-3d below automatically. The manual notes remain here for operators who want to understand or extend what’s happening.
Manual sanitize (if you’d rather)
Run these greps to find anything that needs replacing:
grep -rEi '192\.168\.|skean\.net|aperturelabs|LXC|VLAN' .
For each hit, decide:
- Source code defaults (e.g.
src/ha/rest.rs,src/llm/client.rs): replace internal IPs with empty strings; rely on env vars - docker-compose.yml: replace hardcoded IPs with
${VAR}references; the.env.exampledocuments the variables - Comments: replace any internal-context comments (LXC names, VLAN refs, internal hostnames) with generic equivalents
- Test fixtures: synthetic IPs like
192.168.1.5in unit tests are fine; they don’t expose your deployment
Adopt the new public README + CONTRIBUTING + SECURITY + CHANGELOG + CODE_OF_CONDUCT files:
mv README.public.md README.md
The original README.md becomes docs/INTERNAL_NOTES.md (or just delete it; the new docs supersede it).
Step 4: Final verification
Re-run the sanitization grep. It must return zero hits:
grep -rEi '192\.168\.|skean\.net|aperturelabs|LXC|VLAN' .
Check git log does not include credential commits. Since we’re starting fresh, the new repo has one commit; no history scrubbing required.
Step 5: Initial commit + push
git init -b main
git add .
git commit -m "Initial public release of LocalSky"
# Author: silenthooligan <[email protected]>; no Co-Authored-By trailer
git remote add origin https://github.com/silenthooligan/localsky.git
git push -u origin main
Step 6: Re-verify externally
Clone the published repo fresh to a different directory and repeat the grep. Zero hits required before flipping the repo public.
Step 7: Set repo public
In repo settings on GitHub, change visibility to public.
Step 8: Configure the private overlay
Create aperturelabs/aperturelabs-localsky-deploy on the internal Gitea. Contents:
docker-compose.ymlthat pullsghcr.io/silenthooligan/localsky:latest, mounts_shared/keys, references the internal HA URL and tokens via.env.env(gitignored on public side; tracked here per internal convention).gitea/workflows/deploy-komodo.yml(copied from the original internal repo)- One-line README pointing to the public canonical repo
Komodo continues to deploy from this private overlay. When a new image is published to GHCR, a Gitea webhook bumps the image tag in the overlay and Komodo redeploys.
Step 9: Verify continuity
- The owner’s homelab on LXC 281 now pulls from GHCR instead of the internal registry.
- The dashboard at
http://192.168.20.81:8090still shows the same data. - Zone runs continue to fire on schedule.
- Push notifications still arrive on subscribed devices.
If any of these break, the rotation step probably left a stale value somewhere. Check the internal .env against the public .env.example for every required key.
Step 10: Announce
Post on the homelab forum or similar that you’d like feedback from. The repo is ready for stars.
LocalSky Irrigation, Manual & Rachio Comparison
A self-hosted, sensor-driven, predictive irrigation stack. Built to do everything Rachio does and a substantial amount Rachio refuses to.
Last updated: 2026-05-19
What it is
LocalSky is the irrigation control surface and recommendation engine for a
self-hosted lawn + garden installation. It is the user-facing front end
(weather.skean.net) and the deterministic Rust service behind it; the
back-end stack is Home Assistant + Smart Irrigation + Irrigation Unlimited +
OpenSprinkler + Ecowitt soil sensors + a Tempest weather station.
It runs entirely on local hardware. There is no Rachio-cloud equivalent. The only outbound traffic is an Open-Meteo forecast pull every 30 minutes (free, no API key, no account); everything else, including the dashboard, the SSE event stream, the decision engine, and the run history, lives on your LAN.
The stack is open-source end-to-end. Every component is replaceable.
Architecture
┌──────────────────────────────────────┐
│ weather.skean.net (TLS) │
│ Caddy reverse proxy, OAuth gate │
└──────────────────┬───────────────────┘
│
┌──────────────────────────────────────────────────┴──────────────────┐
│ LXC 281 · LocalSky (Rust / Leptos SSR + WASM) │
│ • Forecast intelligence engine (skip_logic::evaluate) │
│ • 7-day verdict strip + soil-moisture projection │
│ • Live Tempest UDP listener (port 50222) │
│ • Open-Meteo forecast refresher (30 min) │
│ • SQLite history (365-day run log) │
│ • Web Push via VAPID (PWA notifications) │
│ • SSE stream at /api/irrigation/stream │
│ • LLM advisor (the LLM provider, optional) │
└─────────────────────────────────┬───────────────────────────────────┘
│ HA REST (10 s poll) + states bulk
▼
┌─────────────────────────────────────────────────────────────────────┐
│ LXC 279 · Home Assistant + sidecars │
│ • Home Assistant Core (host networking, port 8123) │
│ • Mosquitto MQTT broker │
│ • ecowitt2mqtt sidecar (port 8088), translates GW1100B push │
│ into MQTT discovery; chains raw POSTs to HA's native webhook │
│ • Smart Irrigation HACS, daily ET bucket math per zone │
│ • Irrigation Unlimited, sequence scheduler, per-zone adjust_time │
└──────┬─────────────────────────┬─────────────────────────┬──────────┘
│ REST │ ZHA / Zigbee │ MQTT discovery
▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌────────────────────┐
│OpenSprinkler│ │ Water-leak nodes │ │ Ecowitt GW1100B │
│ 192.168.40.60│ │ (Samjin IM6001) │ │ 192.168.40.61 │
│ DC, latching│ └──────────────────┘ │ + 4× WH52 3-in-1 │
│ 4 zones live│ │ Push every 60 s │
└──────┬──────┘ └────────────────────┘
│ DC pulses (~25 ms)
▼
solenoids ┌──────────────────────────────┐
│ WeatherFlow Tempest hub │
│ 192.168.40.62 │
│ UDP 50222 every 3 s (rapid) │
│ 1 min full obs │
└──────────────────────────────┘
One-paragraph elevator pitch. LocalSky takes a Rust engine that reads both a real on-premise weather station and a 7-day forecast, blends it with calibrated soil sensors and an evapotranspiration bucket per zone, and decides whether and how much to water tomorrow morning. Decisions are expressed as per-zone override calls to a deterministic scheduler (Irrigation Unlimited); execution lands on an open-hardware sprinkler controller (OpenSprinkler). Everything is observable, every threshold is tunable, every line of code is yours.
Hardware bill of materials
| Component | Model | Role | Notes |
|---|---|---|---|
| Sprinkler controller | OpenSprinkler DC (8-zone) | Drives the solenoids | Open hardware, DC + latching solenoid support (Rachio is 24 VAC only). Drinks ~0.5 W idle. |
| Weather station | WeatherFlow Tempest | Hyperlocal weather | All-in-one wind/rain/temp/humidity/pressure/UV/illuminance/lightning. Pushes to your hub on UDP 50222 every 3 s. |
| Hub | Tempest Hub | UDP relay | LAN-only when paired; no cloud account required for local UDP path. |
| Soil sensors | Ecowitt WH52 ×4 | Per-zone soil | 3-in-1: capacitive moisture (FDR), soil temp, EC. AAA battery, IP66. 12-month battery life. |
| Soil hub | Ecowitt GW1100B | RF receiver, custom-server push | Pushes to ecowitt2mqtt every 60 s. Local HTTP API exposes raw AD values. |
| Server | Proxmox host (any x86) | LXC fleet | LocalSky in LXC 281, HA in LXC 279. Modest spec (~2 cores, 4 GB total for both). |
| Reverse proxy | Caddy (LXC 220) | TLS + optional OAuth gate | Bypasses webhook + static asset routes per [feedback_oauth_gate_vs_crossorigin.md]. |
| Optional add-ons | UDM Pro, Cloudflare Tunnel | Edge networking | For remote access. Stack works without any of these. |
Total BOM on a fresh install lands around $400-700 one-time depending on station/sensor choices, with no recurring fees. Rachio 16-zone + Wireless Flow Meter + Valve Monitoring unlock = ~$500-550 with no soil sensors and hard cloud dependency.
Software stack
| Layer | Component | What it owns |
|---|---|---|
| Engine | LocalSky (Rust) | Forecast intelligence, skip/run verdict, 7-day verdict strip, 7-day moisture projection, SSE stream, dashboard, run history, push notifications, LLM advisor |
| Orchestration | Home Assistant | Entity registry, automations, voice integrations, alerting, OAuth surface for the web dashboard |
| ET bucket math | Smart Irrigation (HACS) | FAO-56 daily bucket per zone, ET₀ from Open-Meteo or Tempest, soil/plant type configuration, multiplier overrides |
| Scheduler | Irrigation Unlimited (HACS) | Sequence definition, sun-relative trigger, per-zone preamble, manual run + suspend + adjust_time service surface |
| Hardware bridge | opensprinkler integration | Maps OpenSprinkler stations to HA entities; receives DC-pulse run_seconds commands |
| Sensor bridge | ecowitt2mqtt | Translates Ecowitt push payloads (including the soil_ec_* family the native HA integration drops) into MQTT discovery |
| MQTT broker | Eclipse Mosquitto | Local broker for discovery + state |
| Edge proxy | Caddy | TLS, OAuth, asset bypass |
Feature matrix
Side-by-side with Rachio 3 (the current consumer flagship, May 2026 firmware). “✅” = shipping, “🟡” = partial / requires add-on / opaque, “❌” = not available. Notes follow.
| Dimension | Rachio 3 | LocalSky stack |
|---|---|---|
| Connectivity | ||
| Operates fully offline (no internet) | ❌ (degrades to fixed schedule after ~2 weeks) | ✅ (LAN-only; only outbound is Open-Meteo every 30 min) |
| Local API for automation | ❌ (cloud API only, 1700 calls/day) | ✅ (HA REST + WebSocket + LocalSky REST + SSE + MQTT) |
| Vendor cloud lock-in | ✅ required | ❌ none |
| Subscription required | ✅ for some features | ❌ never |
| Weather inputs | ||
| Hyperlocal forecast | 🟡 (interpolated PWS grid) | ✅ (Open-Meteo lat/lon precise to ~5 km) |
| On-prem weather station | 🟡 (Tempest as 1st-party PWS, cloud-routed) | ✅ (Tempest via direct LAN UDP every 3 s) |
| Tempest UDP rapid_wind (3 s) | ❌ (cloud-only path) | ✅ |
| Lightning event push | ❌ | ✅ (Tempest event field, surfaced as HA event entity) |
| Skip rules | ||
| Rain skip | ✅ | ✅ |
| Freeze skip (air temp) | ✅ | ✅ |
| Overnight freeze look-ahead | ❌ | ✅ (24h forecast min) |
| Soil-temp frost skip | ❌ | ✅ (yard min from WH52 probes) |
| Wind skip | ✅ | ✅ |
| Forecast peak wind skip | ❌ | ✅ (with +5 mph slack vs live limit) |
| 4-hour rain forecast skip | ❌ | ✅ (sums next-4h hourly precip) |
| 3-day probability-weighted rain skip | ❌ | ✅ (Σ daily × prob/100) |
| Heat advisory pre-water | 🟡 (“Heat Wave Boost” premium unlock, separately priced) | ✅ (built-in; Steadman heat index + ET multiplier) |
| Soil sensor support | ||
| First-party soil sensor | ❌ (no Rachio sensor product) | ✅ (Ecowitt WH52, $30/each) |
| Sensor-driven saturation skip | ❌ (virtual bucket only) | ✅ (per-zone moisture ≥ threshold) |
| Sensor-driven frost skip | ❌ | ✅ |
| Per-zone moisture binding | ❌ | ✅ |
| Operator-controlled calibration | ❌ (no sensor exists) | ✅ (HA-side AD-capture, audit trail in logbook) |
| Raw AD value visibility | ❌ | ✅ (separate sensor.<zone>_soil_ad diagnostic entity) |
| EC (fertilizer salt) trend monitoring | ❌ | ✅ (7-day mean + change statistics) |
| Soil temperature surface | ❌ | ✅ (per zone + yard min/max aggregates) |
| Predictive view | ||
| 7-day skip/run preview strip | ❌ (calendar shows next runs, not the engine’s verdict for each) | ✅ (same engine runs against synthetic forecast inputs) |
| 7-day soil moisture projection | ❌ (bucket is opaque) | ✅ (per-zone water-balance trajectory with target band overlay) |
| Forecast ET visibility | ❌ (ET is internal) | ✅ (sensor.open_meteo_eto_today + tomorrow + 3-day avg surfaced) |
| Per-zone bucket exposure | ❌ | ✅ (bucket_mm attribute on sensor.smart_irrigation_<zone>) |
| Per-zone overrides | ||
| Per-zone one-day skip | ❌ (“disable zone” is permanent until re-enabled) | ✅ (irrigation_unlimited.adjust_time(actual="00:00:00") at 23:30:35) |
| Per-zone Kc bump on heat | ❌ | ✅ (adjust_time(percentage=120) when soil temp ≥ heat-stress threshold) |
| Per-zone target band (min ↔ max) | 🟡 (allowed depletion, internal-only) | ✅ (target_min_pct + saturation_pct operator-tunable) |
| Yard-wide saturation skip (engine-level) | ❌ | ✅ (all 4 zones ≥ threshold AND known) |
| Scheduling | ||
| Sun-relative trigger | 🟡 (sunrise as fixed time, no offset before sunrise) | ✅ (IU sun: sunrise, before: 00:30) |
| Inter-zone preamble (DC latching) | 🟡 (Rachio is AC only) | ✅ (preamble: 00:00:02) |
| Manual run with custom duration | ✅ | ✅ |
| Cancel next | ✅ | ✅ |
| Operator visibility | ||
| Engine reasoning surfaced | ❌ (“watered/skipped” with no explanation) | ✅ (skip-check reason string, logbook entries per gate) |
| LLM-generated daily explainer | ❌ | ✅ (the LLM provider via Extended OpenAI Conversation) |
| Run history retention | 🟡 (cloud, undocumented duration) | ✅ (365-day SQLite, queryable) |
| CSV/data export | 🟡 (daily totals only) | ✅ (full SQLite db; arbitrary SQL queries) |
| Notifications | ||
| Push to mobile app | ✅ (Rachio app) | ✅ (HA Companion app) |
| ✅ | ✅ (HA notify services) | |
| Web push to PWA | ❌ | ✅ (VAPID-signed, served by LocalSky) |
| Voice ack | 🟡 (Alexa/Google: cloud route) | ✅ (HA Assist local “GLaDOS” pipeline + Google route) |
| Voice / home integration | ||
| Alexa | ✅ | ✅ (HA Alexa skill) |
| Google Assistant | ✅ | ✅ (HA manual Actions SDK) |
| Apple HomeKit | ❌ (Rachio dropped 2022) | ✅ (HA HomeKit bridge integration) |
| Matter | ❌ | ✅ (HA’s OTBR + Matter Server) |
| Thread | ❌ | ✅ (HA’s OTBR) |
| IFTTT / SmartThings | ✅ | ✅ (HA bridges) |
| Other | ||
| HACS / open-source ecosystem | ❌ | ✅ |
| Per-zone calendar overrides | ❌ | ✅ |
| Multi-property | ❌ (one controller, one app account) | ✅ (one HA instance, many controllers) |
| DC + latching solenoid support | ❌ | ✅ |
| Flow meter | 🟡 (Wireless Flow Meter ~$200-250 add-on, accuracy gripes) | 🟡 (OpenSprinkler flow sensor input; not currently wired) |
Where Rachio still wins, honestly:
- A polished out-of-the-box experience: one box, one app, working in 30 minutes.
- Wider pool of curated PWS data via their hyperlocal model (300k+ stations).
- Single vendor support (one phone number when things break).
- No infrastructure for the user to maintain (no LXC, no Caddy, no certificates).
Where LocalSky genuinely exceeds Rachio, with technical specifics, follows.
Capability deep-dive
1. Local-first operation
LocalSky runs on a Proxmox LXC inside the user’s LAN. The Rust binary plus the SQLite history database is ~80 MB total. There is no Rachio cloud equivalent, when your internet is down, the dashboard, the scheduler, the soil sensors, the verdict engine, and the run history are all still online and decisive.
The only external dependency is Open-Meteo (forecast, every 30 min, free, no API key). If that fails, LocalSky degrades to live Tempest + soil + last-known forecast snapshot. The skip-check rules silently fall back to their default thresholds.
The official Rachio Home Assistant integration is documented “Cloud Push”, when Rachio.com is offline, your HA instance has no idea what the controller is doing.
2. Hyperlocal weather, the actual kind
Rachio’s “hyperlocal” weather is an interpolated grid stitched together from PWS uploads. The user can override it to a specific PWS, including a Tempest station, but the path is cloud-routed. Rachio’s servers ingest Tempest cloud data, blend it with their grid model, and emit a verdict. The verdict reaches the controller via Rachio’s cloud.
LocalSky’s tempest::Snapshot is populated from the Tempest UDP path
directly: port 50222 on VLAN 40, every 3 s for rapid_wind, every
1 min for obs_st. The verdict engine sees the same data the Tempest
hub sees, with no round-trip. Wind compass updates 20× per minute on
the dashboard.
If a thunderstorm sets up over your back yard at 6 AM, LocalSky sees the wind shift and the precipitation start before Rachio’s cloud has even ingested the next Tempest cloud upload.
3. Forecast intelligence (Phase A)
The skip-check engine in src/ha/skip_logic.rs runs 14 rules in priority order. The marketing copy from Rachio (“hyperlocal weather intelligence”) is a bucket of ~4-5 rules. The LocalSky engine has all of those plus:
- Next-4-hour rain skip:
Σ hourly[0..4].precip ≥ 0.10": catches a shower that the daily total would mask. - Probability-weighted tomorrow rain:
forecast_in × prob/100 ≥ rain_skip_in. A 0.4“ forecast at 30% confidence is treated as 0.12“, below threshold; a 0.4“ at 90% confidence trips at 0.36“. - 3-day weighted rollup:
Σ daily[1..4] × prob/100 ≥ 1.5 × thresholdfor catching a multi-day rain event ahead. - Overnight freeze look-ahead: minimum hourly forecast temp in the next 24 h; skips if dipping below the freeze threshold tonight even if the current run-time temp is fine.
- Forecast peak wind:
daily.wind_max_today_mph > max_wind + 5, with the +5 mph slack because forecast peaks routinely overshoot. - Heat advisory (run_extended): if the 3-day forecast peak ≥ 95 °F, humidity ≥ 60%, ≥ 2 dry days, AND less than half a rain-skip’s worth of weighted rain coming → bump the planned run +15% to pre-water ahead of the ET acceleration.
Every rule emits a human-readable reason that surfaces on the
dashboard verdict tile and in the HA logbook. The opaque “Saturation
skip” Rachio shows becomes "Soil saturated (tightest: back yard 72% ≥ 70% threshold)".
4. Soil sensors with operator-controlled calibration (Phase D)
Rachio has no first-party soil sensor and no native third-party binding. Their “saturation skip” is a virtual bucket computed from ET + rain
- soil/plant type. The user has no way to ground-truth it.
LocalSky uses 4× Ecowitt WH52 3-in-1 probes with HA-side
calibration. Each probe reports raw AD (FDR-based capacitance proxy);
the operator runs a Capture DRY (probe in air) and Capture WET
(probe in saturated soil) on the dashboard, and the template sensor
sensor.<zone>_soil_moisture computes
moisture % via a linear map between captured endpoints, clamped 0..100,
with the audit trail in HA’s logbook. The gateway’s own factory
calibration is bypassed and surfaced only as a _raw_pct diagnostic.
Two HA input_number helpers per zone (wh52_<zone>_ad_dry /
_ad_wet) hold the captured values across restarts (no initial: so
the recorder’s restore_state preserves operator captures).
5. Predictive 7-day moisture projection (Phase E)
This is LocalSky’s biggest single advantage over Rachio.
compute_soil_forecasts in src/ha/refresher.rs
walks each daily forecast entry for the next 7 days and runs a
FAO-56-flavored water balance per zone:
delta_mm = rain_mm × precip_probability/100 × CAPTURE_EFFICIENCY
- et0_today_mm × heat_multiplier × zone_Kc
delta_pct = delta_mm / soil_depth_mm × 100
Where:
CAPTURE_EFFICIENCY = 0.7(accounts for runoff, slope, canopy interception, 30% of forecast rain doesn’t make it into the root zone),zone_Kcmatches Smart Irrigation’s multiplier (1.08 for turf, 0.50 for shrubs),soil_depth_mmis the effective root zone (150 mm turf, 200 mm shrubs),heat_multiplieris the same Steadman-derived ET bump the engine applies to today’s plan.
The result is a Vec<SoilForecast> per zone with:
- current_pct (today’s calibrated live reading)
- predicted_pct[7] (each day in the window)
- min/max predicted, days_below_target, days_above_max
- a status pill:
ok/dry/wet/no_data
The dashboard renders each zone as a tile with a 7-day sparkline
overlaid on the target band (target_min_pct ↔ saturation_pct). A
red status pill means the zone is going to fall through the floor at
some point this week even with the forecast rain factored in. A wet
status means heavy forecast rain will push it past saturation.
Rachio’s calendar view shows the planned run; LocalSky’s forecast view shows whether you’ll be in your healthy band next Friday. That’s the difference between “the engine ran something this morning” and “the engine is keeping the lawn alive over the week.”
6. Per-zone surgical overrides (Phase E HA-side)
Rachio has no per-zone calendar overrides. To skip one zone tomorrow, the user disables the entire zone (which then stays disabled until manually re-enabled).
The LocalSky stack runs irrigation_unlimited.adjust_time directly
against IU’s loaded sequence at three points:
23:30:00 smart_irrigation_iu_sync → adjust_time(actual=HH:MM:SS) per zone
23:30:30 smart_irrigation_iu_pre_run → suspend(11h) if LocalSky verdict = skip
23:30:35 irrigation_per_zone_sat_skip → adjust_time(actual="00:00:00")
per zone where moisture ≥ threshold
23:30:40 irrigation_heat_stress_bump → adjust_time(percentage=120)
per zone where soil temp ≥ threshold
Saturation runs before heat-stress so 120% × 0 = 0 (saturation wins
when a zone is both wet and hot). Each rule logs the per-zone decision
to the HA logbook. The morning IU sequence dispatches the modified
durations to OpenSprinkler.
7. Soil-temp frost skip + warm-season binary
Air-temp freeze skip is fine for “don’t water in literal frost.” But the soil temperature is the actual constraint, soil retains heat slower than air, so a 38 °F dawn after a 28 °F overnight has soil still cold enough that sprays freeze on contact and dormant roots won’t drink.
LocalSky’s skip-logic ladder fires Soil frost when
sensor.soil_temp_yard_now_min < irrigation_frost_skip_f (default
35 °F). The yard-min is a min_max aggregate over the 4 zone temps.
Inverse use case: binary_sensor.warm_season_active flips on when
the 7-day rolling minimum of yard-min soil temperature crosses
soil_warm_season_threshold_f (default 65 °F). For Florida
St. Augustine and Bahia, ≥ 65 °F sustained = pre-emergent window
opens. The user can hang their own automation off this, Rachio
doesn’t expose anything like it.
8. EC (electrical conductivity) for fertilizer salt monitoring
Each WH52 also reports soil EC (µS/cm). LocalSky runs statistics sensors for each zone:
sensor.<zone>_ec_mean_7d: rolling 7-day meansensor.<zone>_ec_change_7d: total change across the 7-day window
Rising EC = salt accumulation (fertilizer or coastal intrusion). Sudden drop after rain = leaching event. The dashboard surfaces both plus an EC-flush threshold input_number. No automation acts on EC yet, but the user can see it. Rachio doesn’t surface fertilizer or salt at all.
9. Operator visibility & explainability
Every irrigation decision lands in three places:
- HA logbook entry with timestamp and the per-zone reason
(e.g.
"back_yard saturation-skip: soil 75.2% ≥ 70% threshold; IU zone 1 muted for tomorrow"). - LocalSky
skip_check.reasonin the snapshot JSON, displayed on the verdict tile. - LLM advisor (optional, when the LLM provider is reachable) emits a daily human-language explanation: “Skipping back yard tomorrow because the probe is reading saturated; expecting 0.3” of rain Wednesday so the other zones can wait.“
Rachio shows you “Saturation skip” with no underlying numbers; the advanced settings that drive the decision are app-only and the model itself is closed.
10. Notifications
LocalSky ships VAPID-signed web push so the PWA can wake an iPhone or Pixel home-screen icon directly. Notifications fire on:
- Zone start
- Zone stop
- Daily verdict (the morning verdict, with reason)
- Heat advisory triggered
- Low soil battery (any WH52 binary
on) - Sequence skipped (with the reason)
Rachio is push-to-mobile-app only; no PWA path. The HA stack adds mobile push via the Companion app and voice ack via the local Assist pipeline.
11. History + export
LocalSky maintains a 365-day SQLite-backed run history at
/data/irrigation.db per src/history/. Every IU
finish event, every skip event, every manual run is recorded with
zone, duration, start/end epoch, and (when known) flow volume.
The dashboard surfaces:
- Per-zone Gantt strip (last 14 days)
- Utilization heatmap (daily hours by zone)
- Run-count + total-minutes summary
The SQLite file is queryable from any host (sqlite3 irrigation.db)
for ad-hoc reports. CSV export is a one-liner. Rachio’s app shows a
per-event history list but their CSV export is daily totals only,
a multi-year-open community feature request.
Cost analysis
| Item | Rachio path | LocalSky path |
|---|---|---|
| Controller hardware | Rachio 3 16-zone $250 | OpenSprinkler 16-zone $179 |
| Outdoor enclosure | $30 add-on | $0 (OpenSprinkler IP65 stock) |
| Weather station | Tempest $339 (paired to Rachio cloud) | Tempest $339 (paired to local hub) |
| Soil sensors | Not available (or 3rd-party IFTTT-only) | 4× WH52 $30 each + GW1100B $69 = $189 |
| Flow meter | Rachio Wireless Flow Meter $200-250 | OpenSprinkler flow sensor input (BYO sensor ~$20-40) |
| Valve Monitoring | $30 one-time unlock | Free |
| Heat Wave Boost | Separately priced premium | Free |
| Subscription | $0 base, premium unlocks per-controller | $0 |
| Year-1 total (8-zone, w/ soil) | ~$580 (no soil) | ~$650 (with full soil sensing) |
| Year-3 total | Same + premium per replacement | Same (no recurring) |
| Hidden cost: replace controller | All premium unlocks lost | Bring your config and Postgres/SQLite forward |
| Hidden cost: vendor changes mind | HomeKit dropped 2022 | n/a (you own the stack) |
What you’re trusting vs what you can verify
Every decision LocalSky makes is built from open inputs you can inspect:
sensor.localsky_irrigation_verdict ← the engine's call
sensor.localsky_irrigation_reason ← the human-readable why
sensor.<zone>_soil_moisture ← live calibrated %
sensor.<zone>_soil_ad ← raw FDR AD (no math applied)
input_number.wh52_<zone>_ad_{dry,wet} ← your captured endpoints
input_number.irrigation_<zone>_* ← every threshold, tunable
sensor.smart_irrigation_<zone> ← SI's per-zone duration
sensor.smart_irrigation_<zone>.bucket ← FAO-56 deficit
sensor.open_meteo_eto_today ← reference ET₀
sensor.soil_temp_yard_now_min ← min_max aggregate
binary_sensor.warm_season_active ← 7d rolling soil-temp gate
Every threshold has a dashboard slider. Every automation has a logbook entry. Every decision can be traced from input to output in code that ships with the repo.
Rachio’s equivalent is trust the model. That’s a fine answer for most users; this stack is the answer for the people who want to see the gauges.
Watering-time math (the “why is this zone 30 min?” answer)
Smart Irrigation computes each zone’s planned run-time as:
seconds = ( |bucket_mm| / throughput_mm_per_hr ) × 3600 × multiplier
final = min(seconds, maximum_duration) # safety ceiling
Where each input is operator-tunable:
| Input | Meaning | Per-zone or global |
|---|---|---|
bucket_mm | Soil-water deficit. SI’s FAO-56 bucket; negative when ET has depleted from field capacity. | Per zone |
throughput_mm_hr | The head’s precipitation rate. Fixed sprays land 20-40 mm/hr; rotors 6-14 mm/hr; drip 1-5 mm/hr. | Per zone (manually entered in SI) |
multiplier | Crop coefficient (Kc). St. Augustine warm-season turf runs Kc 1.08 in summer; mulched shrubs ~0.50. | Per zone |
maximum_duration | A safety ceiling so a misconfigured throughput can’t run a zone for 12 hours. Default in SI’s source is 3600 s (60 min). | Per zone |
Two zones with the same bucket deficit and Kc can have very different scheduled run-times if their throughput differs. A rotor zone at 2.6 mm/hr will run ~3× longer than a fixed-spray zone at 7.8 mm/hr to deliver the same depth of water, that’s not a bug, it’s heads spreading water more slowly on purpose (better infiltration, less runoff).
The dashboard’s “Why this duration?” tile (right under the zone-status grid)
surfaces the live numbers per zone so you can see exactly what produced each
run-time. When the safety ceiling is binding (the calculated need exceeds
maximum_duration), that row turns amber and the tile flags the % short.
Weekly water-budget mode (Phase H)
Opt-in per zone: when input_boolean.irrigation_<zone>_weekly_budget_mode
is on, the morning pipeline ignores SI’s daily-bucket flex for that zone
and uses LocalSky’s weekly plan instead. The model:
weekly_budget_mm = input_number.irrigation_<zone>_weekly_budget_in × 25.4
expected_rain_mm = 7d_weighted_forecast × 25.4 × 0.7 (CAPTURE_EFFICIENCY)
needed_mm = max(0, weekly_budget_mm − expected_rain_mm)
mm_per_session = needed_mm / sessions_per_week
seconds_per_session = (mm_per_session / throughput) × 3600 × heat_mult ÷ 0.7
today_seconds = if (days_since_last_run >= 7/sessions)
AND (next_24h_rain_in < 0.10")
AND (needed_mm > 0)
then seconds_per_session
else 0
This is the answer to “saturation is not the goal, keep the lawn in a healthy band for St. Augustine.” Defaults match Florida extension service guidance:
| Zone | Weekly budget | Sessions/wk | Notes |
|---|---|---|---|
| Turf (3 zones) | 1.0“ | 2 | UF/IFAS warm-season guideline |
| Shrubs (mulched) | 0.5“ | 1 | Mulch retains, slower dry-down |
All four numbers are operator-tunable per zone via input_number
helpers. The “Weekly water budget” bento card on the irrigation page
shows each zone’s plan + reason for today’s recommendation (skipped
because rain incoming, skipped because ran 2 days ago, or running
because last run was 4 days ago and the next 24h is dry).
The pipeline order:
23:00:00 Smart Irrigation calc -> sensor.smart_irrigation_<zone>
23:30:00 smart_irrigation_iu_sync writes SI values into IU.adjust_time
23:30:25 localsky_weekly_budget_override (NEW)
for zones with mode=on, overwrites with LocalSky's plan
23:30:30 smart_irrigation_iu_pre_run_skip_check (LocalSky + frost)
23:30:35 irrigation_per_zone_saturation_skip
23:30:40 irrigation_heat_stress_kc_bump
sunrise -15m IU sequence finishes (anchor: finish)
The override at 23:30:25 lands between the SI sync and the verdict check so per-zone budget overrides win over SI, but the LocalSky verdict (skip/run/skip-on-frost) still has the final say on whether the whole sequence runs.
Scheduling: finish before sunrise, not start before sunrise
Irrigation Unlimited supports anchor: finish on a schedule, which inverts
the time semantics: instead of “trigger at this time”, it’s “finish at this
time”. The configuration in this repo uses:
schedules:
- name: Finish 15 min before sunrise
anchor: finish
time:
sun: sunrise
before: "00:15"
So the sequence is timed to finish 15 minutes before sunrise. IU computes
the start time automatically as sunrise − 15min − total_sequence_seconds.
When the SI → IU sync at 23:30 rewrites per-zone durations, the start time
auto-adjusts; no automation needed.
This matches arborist guidance for warm-season grasses: water finishing before peak ET hits gives the lawn ~15 min to drain so canopy + soil aren’t standing wet when direct sun arrives. Reduces fungal pressure on St. Augustine in humid climates.
Roadmap
| Phase | Status | What it adds |
|---|---|---|
| A | done | Forecast intelligence engine (14 rules) |
| B | done | HA reads LocalSky verdict via REST sensor |
| C | done | Heat-stress Kc bump into Smart Irrigation |
| D | done | Ecowitt GW1100B + 4× WH52 via ecowitt2mqtt sidecar with HA-side calibration |
| E | done | Soil-aware verdict (frost + yard-wide saturation) + per-zone IU.adjust_time overrides + 7-day moisture projection + dashboard tiles |
| F | done (degraded) | LLM advisor (the LLM provider); offline when vLLM unreachable |
| PWA | done | Manifest, service worker, web push, mobile shell |
| E.1 | done | “Why this duration?” math tile per zone + per-zone history visualization (summary tiles, daily bar chart, recent-runs list) + sequence anchored to finish before sunrise |
| H | done | Weekly water-budget mode: per-zone opt-in scheduler that allocates a weekly water target (default 1.0“ turf / 0.5“ shrubs, operator-tunable) across N sessions/week (default 2 turf / 1 shrubs), subtracts probability-weighted forecast rain × 0.7 capture, defers when next-24h rain ≥ 0.10“ or the zone ran within the minimum interval (7 / sessions days), and emits today’s recommended seconds. HA’s localsky_weekly_budget_override automation at 23:30:25 reads sensor.localsky_<zone>_budget_seconds and overrides SI’s IU value via irrigation_unlimited.adjust_time(actual=HH:MM:SS). Per-zone input_boolean.irrigation_<zone>_weekly_budget_mode toggles each zone between budget mode and SI’s daily flex independently. |
| G (planned) | Per-zone forward verdict (when Smart Irrigation moisture binding lands upstream, or via local moisture-driven set_bucket overrides) | |
| I (planned) | Flow-meter integration (OpenSprinkler flow input → real-time leak detection + per-event volume) | |
| J (planned) | EC-driven flush-watering recommendation (informational → optional automation) | |
| K (planned) | Forecast ET vector (Open-Meteo per-day et0_fao_evapotranspiration) added to DailyEntry so the 7-day projection uses real future ET instead of today’s value |
Quick start (one-paragraph summary)
If you’ve got a Proxmox host, a few hundred dollars of hardware, and
patience, you can clone the aperturelabs-localsky and
aperturelabs-home-automation repos, paint the gateway IPs and zone
names to match your install, wire OpenSprinkler to your solenoids,
plant 4 WH52 probes, point them at a GW1100B, and have a verdict
engine running by tomorrow morning. The dashboard is one TLS hop
behind Caddy and one Tailscale jump away when you’re not home.
It will out-explain Rachio, out-observe Rachio, and outlast Rachio’s next pivot.
License
Same as the rest of the Aperture Labs stack: internal use, repo lives in the user’s Gitea, source open to anyone with read access.
Where this doc lives
Canonical: aperturelabs-localsky/docs/src/manual.md Mirror: aperturelabs-home-automation/docs/localsky-irrigation/README.md
Update either, then make-mirror (TODO: tooling, currently a manual cp).
LocalSky 0.1.0 Launch Checklist
Source of truth for what has to be true before silenthooligan/localsky is flipped public and tagged v0.1.0. Read top to bottom. Do not skip steps.
The fork model is: silenthooligan/localsky is the public canonical repo. aperturelabs/aperturelabs-localsky-deploy is the private overlay that mounts the homelab .env and points Komodo at the public GHCR image. See migration.md for the mechanics.
Phase 1 - Secrets rotation (do this first)
Any credential that has ever touched the internal git history is treated as leaked.
- Revoke the current
HA_LONG_LIVED_TOKENfrom the HA UI; mint a new one and store it only inaperturelabs-localsky-deploy/.env - Regenerate the VAPID keypair; old public key retired from every active subscriber; new keys in the private overlay only
- Mint a new fine-grained GitHub PAT scoped only to
silenthooligan/localsky(read/write contents, packages, actions). Stash asGHCR_PATin the private overlay - Rotate the OpenSprinkler password MD5 if it has ever been committed
- Do not reuse the
ghp_*PAT documented inaperturelabs-core-manager/secrets.md- that one is scoped for Renovate and HACS, not for this repo
Phase 2 - Code + repo hygiene (on the v2 branch)
-
cargo fmt --checkclean -
cargo clippy -- -D warningsclean -
cargo check --features ssrzero warnings -
cargo check --features hydrate --target wasm32-unknown-unknownzero warnings -
cargo test --features ssrall passing (157+ as of 2026-05-20) -
cargo deny checkclean - No
Co-Authored-By: Claudetrailer anywhere in v2 commit history - No em dashes in README, CHANGELOG, CONTRIBUTING, SECURITY, or any doc that ships to the public site
Phase 3 - Sanitize for public
The scrub script lives at scripts/sanitize-for-public.sh. It produces a clean tree in /tmp/localsky-seed/.
- Run
scripts/sanitize-for-public.sh; review the output -
grep -rEi '192\.168\.|skean\.net|aperturelabs|omega-|LXC|VLAN|10\.1\.100' /tmp/localsky-seed/returns zero matches - No
.env(only.env.example) - No
.gitea/directory - No
_shared/,data/,target/,dist/,*.pem -
Dockerfileuses non-root user (useradd --uid 10001 localsky,USER 10001:10001) -
docker-compose.ymlreferencesghcr.io/silenthooligan/localsky:latest, no internal registry refs, env via${VAR}from.env.example -
Cargo.tomlhaslicense = "Apache-2.0",repository = "https://github.com/silenthooligan/localsky",homepage = "https://github.com/silenthooligan/localsky"
Phase 4 - Public repo bootstrap
- Create
silenthooligan/localskyon GitHub (start private) - Single initial commit from the sanitized seed, author
silenthooligan <[email protected]>, message"Initial public release of LocalSky" - Push to
main - Clone fresh into a tmpdir; re-run the grep sweep; zero matches required before flipping to public
- Enable GitHub Discussions
- Verify issue templates render correctly
- Verify pull request template renders correctly
- Verify
SECURITY.mdcontact (set up the forwarder; test message delivers) - Flip repo visibility to public
Phase 5 - Build, scan, ship
- GitHub Actions builds
ghcr.io/silenthooligan/localsky:0.1.0and:latest - Multi-arch:
linux/amd64+linux/arm64 - Trivy scan: zero HIGH+ vulnerabilities
- SBOM published as a release asset
-
docker inspect ghcr.io/silenthooligan/localsky:0.1.0showsUser: 10001 - mdBook docs site builds + deploys to GitHub Pages from the
v0.1.0tag
Phase 6 - Real-world boot tests
Each must pass on a clean machine before tagging v0.1.0.
- Demo boot:
docker run -e LOCALSKY_DEMO=1 -p 8090:8090 ghcr.io/silenthooligan/localsky:0.1.0boots and serves UI; sparkline + zone math visible; cycling verdicts - First-run wizard: no env vars, no
/data/localsky.toml-> boots to/setup/welcomeon both desktop and mobile viewport; completes end-to-end; written TOML is valid; no process exit - Legacy continuity: HA env vars present, no
/data/localsky.toml->env_compatsynthesizes aha_service_callcontroller +ha_passthroughsource; existing/data/irrigation.dbmigrates cleanly (M0001 -> latest); UI identical to v0.1 - Standalone: only
tempest_udp+open_meteoconfigured (no HA, no MQTT) -> dashboard works;/api/healthreportsha: not_configured - Standalone with MQTT:
mqtt_subscribesource pulls soil moisture from an external broker; sensor values land insensor_history - Ecowitt LAN: GW1100 pointed at
http://localsky:8090/ingest/ecowitt; packets ingested and visible in/api/health
Phase 7 - Engine correctness (offline regression)
- Penman-Monteith vs Open-Meteo’s
et0_fao_evapotranspirationon 7 days of recorded Tempest data: RMSE within +/- 0.5 mm/day - Hargreaves fallback vs PM: within +/- 20% (typical bias documented)
- Cycle-and-soak:
(soil=clay, slope=5%, precip=15 mm/hr, runtime=45 min)produces 3 cycles of 15 min + 30 min soaks - Skip-rule replay: load production
verdict_history, replay each row’sinputs_jsonthroughengine::skip_rules-> 100% verdict + reason match on a 30+ day window
Phase 8 - Owner homelab cutover
The private deploy overlay is the only thing that changes on Aperture.
-
aperturelabs-localsky-deployrepo created on internal Gitea withdocker-compose.yml,.env,.gitea/workflows/deploy-komodo.yml, README - Komodo resource sync targets the new repo
- Old
aperturelabs-localskyrepo on internal Gitea marked archived (do NOT delete; v0.1.0 history lives there) - First image pull on LXC 281 succeeds via Gitea webhook -> Komodo
DeployStack - 24-hour stability soak with the public image:
- Tempest UDP bind survives container restart
- Skip-check evaluations stable across the day
- Zone history continuity (no run loss across restart, no duplicates)
-
/api/healthstays green for all configured sources + controllers - No new errors in logs that did not exist on v0.1
Phase 9 - Public release
Only after every box above is checked.
- Tag
v0.1.0onsilenthooligan/localsky - GitHub release notes copy the
[0.1.0]CHANGELOG section verbatim - Cross-arch binary attachments via
cross+softprops/action-gh-release - Cosign keyless OIDC signature on each artifact + the GHCR image
- Hardware compatibility table in README lists only
Testedrows; community + planned rows clearly labeled - At least 5 screenshots in
docs/assets/screenshots/(captured via demo mode) - Announce in (a) personal channels only; do not submit to HN / Reddit / lobste.rs until at least 7 days of soak with no critical bug
Hard “do not ship” conditions
If any of these are true at the planned cutover, do not flip public:
- The grep sweep at the end of Phase 3 returns any match
- Any
cargowarning anywhere - Trivy reports any HIGH+ vulnerability
- The demo mode boot does not serve a full dashboard with cycling verdicts
- The first-run wizard cannot complete without manual TOML editing
- The legacy-continuity boot path does not produce a verdict that matches the v0.1 production verdict for the same day
Post-launch follow-ups (not blocking 0.1.0)
- Hosted demo at
demo.<domain>if a domain is registered - HACS publishing (see hacs.md)
- ESPHome native + Rachio controller adapters (planned 0.2)
- Ambient Weather + Pirate Weather sources (planned 0.2-0.3)
- Telemetry opt-in (Plausible at self-hosted endpoint; version + arch + enabled-adapter-types only)
- axe-core CI job (a11y gate; zero serious violations)