Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LocalSky

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 knowChapter
What weather sources LocalSky can readSensors
How the engine decides whether to waterIrrigation engine + Skip rules
Which grass species the catalog supportsGrass species catalog
Which soil textures the catalog supportsSoil texture catalog
Which controllers LocalSky drivesControllers
Every config optionConfiguration reference
Every REST + SSE endpointREST + SSE API
How the UI is supposed to feelUX journey
Upgrade from v0.1Migration 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.

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.

  1. Welcome: accept the Apache-2.0 license. Telemetry defaults off.
  2. Location: latitude + longitude in decimal degrees. Elevation optional; improves FAO-56 ET₀. Timezone optional too (derives from lat/lon at boot).
  3. Sources: informational. Auto-creates a Tempest UDP listener (in case you have one) + Open-Meteo forecast. Full editor under /settings/sources post-wizard.
  4. Controllers: informational. Auto-detects HA env vars and creates an HA-service-call controller if present; otherwise add one under /settings/controllers.
  5. Zones: informational. Configure under /settings/zones.
  6. LLM: pick a provider, or leave at “Auto” or “None”.
  7. Notifications: Web Push, MQTT, ntfy, Slack (all independent + optional).
  8. Review: click Save and finish. Settings write to /data/localsky.toml atomically 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:

  1. Run the install command above without setting HA_URL or MQTT_HOST.
  2. In the wizard’s Controllers step, configure your direct-controlled controller (OpenSprinkler is the canonical example).
  3. Done. LocalSky’s dashboard becomes your irrigation surface; the engine drives zones directly.

What this gets you:

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

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

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:

  1. Same install command.
  2. Configure your controller directly under /settings/controllers.
  3. Under /settings/notifications, set the MQTT broker host (your existing HA broker, or any other).
  4. 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, and sensor.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:

  1. Pass HA_URL and HA_LONG_LIVED_TOKEN env vars to the container.
  2. In the wizard’s Controllers step, pick ha_service_call. Map your LocalSky zones to HA entity ids.
  3. 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 typeUnlocks
Soil moisture (Ecowitt WH51 / WH52, Aqara, Sonoff)Per-zone saturation skip, soil-moisture projection, smarter dry-out detection
Soil temperatureSoil-frost skip rule (catches the “cold soil + sprinkler = frozen lawn” case better than air temp alone)
Rain gauge (separate from weather station)Improves rain-today accumulation accuracy
Lightning detectorPowers the lightning panel + safety skip during active storms
Flow meter (on controller)Validates actual delivered water vs. computed mm depth

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

Optional: Local LLM

LocalSky’s advisor produces plain-English explanations of why today’s verdict is what it is. 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 /setup or /settings/zones.
  • Verdict shows “(weather rules only; soil rules offline)”: a soil moisture probe isn’t reporting. Check the source under /settings/sources.
  • LLM advisor is grayed out: provider is unreachable. Visit /settings/llm.
  • MQTT discovery isn’t creating entities in HA: HA’s MQTT integration needs the broker connected (Settings → Devices & Services → MQTT → Configure). Discovery topics live under homeassistant/<component>/<your-deployment-slug>/....
  • Container won’t start on Raspberry Pi: confirm 64-bit OS (uname -m should report aarch64). 32-bit Pi OS is not supported.

Next steps

Standalone Mode (No Home Assistant)

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

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

What “standalone” gets you (the short answer)

Everything. The full LocalSky feature set runs without HA:

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

Hardware that works this way

DeviceHow it gets to MQTTLocalSky path
ESPHome-flashed ESP32 + sensorNative MQTT publish (or via HA’s MQTT integration)Subscribe to esphome/<device>/<sensor>/state
Tasmota-flashed deviceNative MQTT publishSubscribe to tasmota/<device>/SENSOR
Zigbee sensors (Aqara, Sonoff)Via Zigbee2MQTT (no HA needed)Subscribe to zigbee2mqtt/<friendly_name>
Ecowitt gateway (WH51, WH52)Via ecowitt2mqtt sidecarSubscribe to ecowitt/<device_id>
Shelly devicesNative MQTT (firmware setting)Subscribe to shellies/<device>/<field>
Arbitrary Arduino / Pi projectPubSubClient / paho-mqttSubscribe to whatever topic you publish

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

Path 2: Direct LAN adapters

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

SensorAdapterStatus
Tempest hub (UDP broadcast 50222)tempest_udpTested
Ecowitt GW1100 / GW2000 (HTTP POST)ecowitt_localPlanned
Ambient Weather (socket.io)ambient_weatherPlanned
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:

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:

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

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

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

The “I’m against HA” reality check

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

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

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

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

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

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

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_mm
  • sensor.localsky_<zone>_et_today_mm
  • sensor.localsky_<zone>_planned_seconds
  • binary_sensor.localsky_<zone>_running
  • sensor.localsky_verdict_today
  • sensor.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:

  1. Different release cadence: HA integrations need to track HA’s quarterly version. LocalSky shouldn’t be coupled to that schedule.
  2. Different runtime: LocalSky is Rust + WASM. The HACS integration is Python. Mixing the two in one repo complicates CI without payoff.
  3. 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/health endpoint reliable for the coordinator’s “is the host up?” check
  • /api/irrigation/snapshot schema 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 Lovelace
  • localsky-zone-card: a single-zone status card with bucket bar + planned-run countdown
  • localsky-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 versionHACS dependency status
0.1not viable yet (API wire format not stable)
0.2API stabilizes; HACS project can start
0.5HACS integration alpha, community-tested
1.0LocalSky 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 degrees
  • location.elevation_m: optional, used by FAO-56 net-radiation
  • units: "imperial" (default) or "metric". Per-field overrides live in browser localStorage, not here
  • timezone: optional IANA name. Null derives from lat/lon at boot
  • display_name: surfaces in the MQTT discovery node_id (slugified) and the dashboard title

[features]

[features]
demo_mode             = false
enable_mqtt_publish   = true
enable_advisor        = true
enable_push           = true
nerd_mode_default     = false
telemetry             = false

All defaults shown. demo_mode swaps every controller for DryRun and uses the synthetic DemoReplay source.

[[sources]]

A list. Each entry has an id, priority, enabled, and a kind discriminator with per-kind config block.

[[sources]]
id = "tempest_lan"
priority = 100
enabled = true
kind = "tempest_udp"
[sources.config]
bind_addr = "0.0.0.0:50222"
hub_serial = null  # filter to a specific Tempest hub; null = accept any

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

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

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_history ingestion 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

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

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

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

Where OpenSprinkler shines

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

Where OpenSprinkler falls short

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

Home Assistant service call (legacy continuity)

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

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

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

Use cases:

  • Migrating from an HA-driven irrigation setup without re-wiring schedules
  • Using a controller LocalSky doesn’t have a native adapter for (Hunter, B-hyve via HA), but the HA integration does
  • Wanting irrigation runs to flow through HA’s automation engine for additional logic

ESPHome sprinkler (community / planned)

ESPHome’s sprinkler component turns an ESP32 with a relay board into a smart irrigation controller for ~$15-40 total parts cost. The native API (protobuf over TCP) is documented.

[[controllers]]
id = "esp_irrigation"
default = true
kind = "esphome_native"
[controllers.config]
host = "192.0.2.20"
port = 6053
password = "${ESP_API_PASSWORD}"
[controllers.config.zone_entity_map]
back_yard = "switch.back_yard_valve"
front_yard = "switch.front_yard_valve"

Status: trait scaffolded, native adapter implementation deferred. Until then, run the ESPHome device under HA and use the ha_service_call controller. Track progress at the relevant GitHub issue.

Rachio Gen 2/3 (planned)

Rachio is cloud-tethered but well-documented. The v1 API takes a bearer token and exposes zone start, zone stop, schedule query.

[[controllers]]
id = "rachio_main"
default = true
kind = "rachio"
[controllers.config]
api_token = "${RACHIO_API_TOKEN}"
device_id = "..."
[controllers.config.zone_uuid_map]
back_yard = "..."  # Rachio zone UUID

Status: schema variant exists, adapter implementation deferred. Until then, drive your Rachio through HA’s Rachio integration and use ha_service_call.

Hunter Hydrawise / B-hyve / others (community)

Both speak cloud APIs that HA integrations exist for. The LocalSky path until native adapters exist: drive them through HA + ha_service_call.

DryRun (no-op)

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

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

LOCALSKY_DEMO=1 env auto-creates this controller.

Multi-controller setups

The ControllerRegistry supports any number of controllers. Use cases:

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

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

Adding a new controller

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

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

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

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

Sensors

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

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

Always-on baseline (no sensors required)

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

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

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

Optional sensors and what they unlock

Soil moisture probes

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

Unlocks:

  • Yard-wide saturation skip rule: when every zone reports moisture at or above its saturation threshold, the engine skips the run.
  • Per-zone soil moisture display: a horizontal bar per zone showing current moisture vs. target band.
  • Soil-moisture projection: 7-day forward curve under no-irrigation, color-coded for “stays in healthy band” vs. “will dry out”.
  • Smarter dry-out detection: catches the case where ET-based math underestimates actual drying (heavy clay holding water visibly longer than expected, or sandy spots draining faster).

Connect via: 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:

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

Example: the soil moisture panel renders as:

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

Once a source is providing the field, the tile lights up and the skip rules incorporating that field activate automatically. The engine never blocks on missing sensor data; weather + ET-based math is the always-on baseline.

Hardware compatibility matrix

SensorDirect adapterVia HANotes
Tempest hub (UDP)Tested (v0.1)YesAir temp, humidity, wind, solar, lightning, rain, pressure
Ecowitt GW1100/GW2000 LANPlannedYes (ecowitt2mqtt)All sub-sensors via the gateway
Ecowitt WH51/WH52 (soil)Via gatewayVia ecowitt2mqttBattery-powered; 868/915 MHz
Aqara ZigbeeVia HAYesSoil moisture + temp probes; needs Zigbee coordinator
Sonoff ZigbeeVia HAYesSame as Aqara
Ambient WeatherPlannedYesCloud API; socket.io
AcuRite tipping bucketVia Ecowitt or HAYes
PurpleAir / AirGradientDisplay onlyYesNo engine integration
OpenSprinkler flow sensorNativeYesRead via /jc water level field

Adding a new sensor source

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

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

“What if I have no sensors at all?”

You’ll get:

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

You won’t get:

  • Soil saturation skip (the engine assumes the bucket model is correct, which it usually is)
  • Soil frost skip (covered by air-temp freeze rules)
  • Flow-validated runs (the engine trusts that the controller ran the requested duration)

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

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 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 vapid warnings; 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/subscriptions to 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

EventChannelsTrigger
Zone startedWeb Push, MQTT, ntfy, SlackA zone’s running flag flips from off to on
Zone stoppedWeb Push, MQTT, ntfy, SlackA zone’s running flag flips from on to off (carries duration in minutes)
Daily verdictWeb Push, MQTT, ntfy, SlackFirst 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. 13
  • Rn – net radiation (MJ/m²/day), eq. 38 + 39 + 40
  • G – soil heat flux (~0 for daily timescale over grass)
  • γ – psychrometric constant (kPa/°C), eq. 8 = 0.665e-3 × P
  • T – mean daily temperature (°C)
  • u₂ – wind at 2m (m/s)
  • es – saturation vapor pressure (kPa), eq. 11 + 12
  • ea – actual vapor pressure (kPa), eq. 14-19 depending on humidity inputs

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

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

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

Implementation: src/engine/et0.rs. Hand-trace tested against eq. 6 for a 50°N April day (Tmax 21.5, Tmin 12.3, RH 84/63, u₂ 2.78, Rs 22.07): ~3.51 mm/day.

2. ASCE-EWRI 2005 short-crop reference ET

Practically identical to FAO-56 for daily computation; the coefficients differ at sub-daily resolution where LocalSky doesn’t operate. Same code path, different et0_method label for operators who want their dashboards to read “ASCE” instead.

3. Hargreaves-Samani 1985

Fallback when wind, solar, or humidity are missing:

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

Typical bias vs. PM is +/- 15-25% 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_water is the depth (mm) of irrigation that reached the soil during this tick.
  • TAW (Total Available Water, mm) = (FC - WP) * root_depth_mm. FC and WP come from the soil texture catalog (USDA classes, sourced from FAO-56 Table 19 and USDA NRCS Part 652).

Trigger to irrigate:

needs_irrigation = (depletion >= RAW)
RAW = TAW * MAD%

MAD (Management Allowed Depletion) defaults per species. St. Augustine: 50%. Bahia: 55%. Ornamental shrubs: 40%. The catalog cites UF/IFAS 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:

columnsource
predicted_inThe morning’s forecast (forecast.daily[0].precipitation_sum). First write of the day wins.
observed_inThe day’s end-of-period observed rain from the merged snapshot. Updated as the day accumulates.
month1..12, denormalized so the bias query indexes by month-of-year.

The first write of the day plants the prediction; the rest of the day refines the observation. Once MIN_OBSERVATIONS (currently 5) days exist in a given month within the rolling 90-day window, the engine computes a per-month bias multiplier:

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

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

Where it surfaces

  • API: GET /api/v1/forecast/bias returns the current-month multiplier plus the full 12-month table with sample counts.
  • Pure module: engine::forecast_bias::BiasModel::from_observations(observations, today, window) is callable from anywhere; ideal for backtests and replay against historical verdict logs.
  • Skip rules: v0.1 surfaces the model and persists the observations but does not yet multiply the rain inputs going into the skip ladder. A v0.2 release will wire corrected_rain = raw_rain * multiplier upstream of skip_rules::evaluate so the morning verdict reflects the learned bias automatically.

Defaults and bounds

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

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

Where to read further

Skip Rules

LocalSky’s irrigation skip-check is a 17-rule ladder. Every morning (or whenever the engine recomputes), inputs flow through the ladder in order. First matching rule wins. Order matters: explicit overrides beat safety beats current conditions beat forecast beats heat advisory beats dry-run beats run.

Source: src/engine/skip_rules.rs.

Ladder

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

Verdict types

The ladder returns one of three verdicts:

  • skip: don’t irrigate. reason carries a human-readable explanation.
  • run: proceed with the engine’s computed runtime.
  • run_extended: proceed at 115% of the engine’s computed runtime. Used only by rule 16 (heat advisory pre-water).

Per-rule details

Currently raining (rule 5)

Live precipitation intensity from the Tempest hub (or merged from any source advertising RainIntensityInHr). 0.01 in/hr 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:

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

Contributing a species

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

  • 12 monthly Kc values (mid-month anchors)
  • Default root zone depth (mm)
  • Default MAD percentage
  • A citation: FAO-56 Table 12, 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.

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

How the values map into the engine

Total Available Water (TAW)

TAW_mm = (FC - WP) * root_depth_mm

This is the depth of water the zone can hold between field capacity (fully wet, no gravity drainage) and the wilting point (so dry the plant gives up). St. Augustine on sandy loam at the default 150 mm root depth: TAW = (0.23 - 0.10) * 150 = 19.5 mm. 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

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

Jar test

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

When in doubt

If you genuinely don’t know, sandy loam is the safest “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

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:

  1. Validates structurally (serde decode)
  2. Validates semantically (unique ids, exactly one default controller, lat/lon in range, etc.)
  3. Snapshots the previous config into config_snapshots (retention 20)
  4. Atomically writes /data/localsky.toml (write to .tmp, fsync, rename)
  5. 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

  1. docker run returns. Container is up.
  2. Visit http://<host>:8090. Server detects no /data/localsky.toml. Redirects to /setup/welcome.
  3. 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.
  4. Step 8 (Review) presents the final summary + a single “Save and finish” button. POSTs /api/wizard/apply:
    • Validates the draft
    • Writes /data/localsky.toml atomically (write to .tmp, fsync, rename)
    • Records a snapshot in config_snapshots (version 1)
    • Deletes the draft file
  5. Server re-mounts normal routes. Dashboard appears at /.

Gaps + how LocalSky handles them

GapHandling
Browser refresh mid-wizardDraft is persisted server-side after each step; refresh resumes at the same step with same values
Container restart mid-wizardSame: draft survives restart
User closes tab and comes back days laterDraft still there. The wizard banner on the dashboard (“Resume setup”) invites resumption
Wizard finishes but config validation failsApply returns 422 with the specific field error inline; on-disk file untouched; draft preserved
First boot has no location enteredLat/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 licenseApply 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_source etc.) 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

  1. Container starts. Reads /data/localsky.toml. schema_version field decides what migration path applies.
  2. 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_migrations table; idempotent if you rerun.
  3. Config loader checks cfg.schema_version. If lower than CURRENT_SCHEMA_VERSION (currently 1), runs the registered migration chain (one function per version bump). Writes the migrated config back.
  4. 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.
  5. Boot completes. Dashboard at the same URL, same data, same zones.

Gaps + how LocalSky handles them

GapHandling
New config schema version releasedconfig/migrate.rs runs the v-to-v+1 chain. Operator does nothing
New DB schema version releasedpersistence/runner.rs applies the new SQL migrations idempotently
Operator skips multiple versionsMigrations are chained; v0.2 → v0.5 runs M0006, M0007, … M0012 in order
New release adds a required config fieldOld configs missing the field are accepted: serde fills with the default declared in the schema
New release removes a deprecated fieldSerde 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 versionIf 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 succeedsEach migration runs in a single transaction. Partial application is impossible: rusqlite commits or rolls back
DB lock from prior processIdempotent: 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

  1. Server validates the incoming Config (serde decode + semantic validation).
  2. If valid: snapshot the previous Config into config_snapshots (retention 20), write the new TOML atomically.
  3. Broadcast a ConfigEvent over the runtime’s tokio::sync::broadcast channel. Interested subsystems re-read their slice:
    • ZoneUpdated(slug) → engine picks up new Kc/soil/MAD on next tick
    • SourceAdded(id) → registry spawns a new adapter task
    • ControllerChanged(id) → registry swaps the pointer atomically; in-flight runs complete on the old controller
    • LlmChanged(...) → advisor reconfigures on next call (cache TTL respects the new config)
  4. If invalid: return 422 with structured field-by-field errors. On-disk file untouched.

Gaps + how LocalSky handles them

GapHandling
User saves an invalid config422 + inline field errors; settings UI surfaces each next to the relevant field
Two users edit simultaneouslyLast 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 onValidator rejects: “zone X references source Y which is not configured”
User removes the default controllerValidator rejects: “at least one controller must have default = true”
User pushes a config that crashes the engineEngine 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 changePOST /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 version field 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:

  1. Connect the sensor’s physical hardware (battery, Zigbee pair, network plug).
  2. Decide the path: direct LAN adapter (e.g. Tempest), MQTT subscribe (Tasmota / ESPHome / Zigbee2MQTT), Ecowitt POST receiver, or HTTP webhook (docs/standalone.md).
  3. Add a source entry under /settings/sources. Optionally test the connection via the “Test” button.
  4. Save. The Runtime spawns a new source task; observations start flowing immediately.
  5. If the sensor is per-zone (soil moisture, soil temp), reference it from the zone editor: ZoneConfig.soil_sensor_id.

Swapping controllers

The flow:

  1. Add the new controller under /settings/controllers alongside the old one. Test connection.
  2. Per-zone: update ZoneConfig.controller_id to point to the new one. Save.
  3. When confident, mark the new controller as default = true (the validator enforces exactly one default).
  4. 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)

  1. 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”).
  2. Remove the dead source. Verify no field reverts to a stale value.
  3. 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

  1. /settings/zones → Add.
  2. Species + soil texture + sprinkler PR + controller mapping.
  3. Save. Engine starts tracking the bucket from “fully wet” assumption (depletion_mm = 0); the operator can adjust by observation.

Removing a zone

  1. /settings/zones → delete row.
  2. 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

GapHandling
Sensor disconnects unexpectedlySource-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 offlineStatus 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-runRestart 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 fieldLower 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-destructivelyPOST /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

GapHandling
Mode transitions are step-wise; no “convert mode 1 to mode 2” wizardDocumented as a checklist; auto-converter is planned
HA passthrough → MQTT subscribe migrationBoth 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:

  1. Wire address-to-lat/lon geocoding into the Location wizard step (server endpoint exists; UI input doesn’t call it yet)
  2. Test buttons in Sources / Controllers / LLM steps (501 → real)
  3. Sensor list editor in /settings/sources (with add/remove/test affordances)
  4. Controller list editor in /settings/controllers (with scan-zones + test-fire)
  5. Zone list editor in /settings/zones (with species picker that uses the grass-species images + calibration modal for catch-cup measurement)
  6. Optimistic-locking version on PUT /api/config (so simultaneous edits don’t silently overwrite)
  7. Pre-migration snapshot (one extra config_snapshots row when schema_version bumps; gives clean rollback even for schema migrations)
  8. Sensor last-seen panel (per-source freshness display)
  9. Zone delete confirmation modal (with run-history download)
  10. 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

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:

  1. In Home Assistant: revoke HA_LONG_LIVED_TOKEN and mint a fresh one. Update the internal .env.
  2. Regenerate the VAPID keypair:
    openssl 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 '='
    
    Update VAPID_PUBLIC_KEY in .env and replace the file at _shared/keys/vapid-private.pem. Re-subscribe any active devices.
  3. Mint a new fine-grained GitHub PAT scoped to silenthooligan/localsky only with contents: write, packages: write, actions: write. Stash in the private overlay’s .env as GHCR_PAT. Do NOT reuse the existing PAT documented in aperturelabs-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.sh

The 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.example documents 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.5 in 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.yml that pulls ghcr.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:8090 still 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

ComponentModelRoleNotes
Sprinkler controllerOpenSprinkler DC (8-zone)Drives the solenoidsOpen hardware, DC + latching solenoid support (Rachio is 24 VAC only). Drinks ~0.5 W idle.
Weather stationWeatherFlow TempestHyperlocal weatherAll-in-one wind/rain/temp/humidity/pressure/UV/illuminance/lightning. Pushes to your hub on UDP 50222 every 3 s.
HubTempest HubUDP relayLAN-only when paired; no cloud account required for local UDP path.
Soil sensorsEcowitt WH52 ×4Per-zone soil3-in-1: capacitive moisture (FDR), soil temp, EC. AAA battery, IP66. 12-month battery life.
Soil hubEcowitt GW1100BRF receiver, custom-server pushPushes to ecowitt2mqtt every 60 s. Local HTTP API exposes raw AD values.
ServerProxmox host (any x86)LXC fleetLocalSky in LXC 281, HA in LXC 279. Modest spec (~2 cores, 4 GB total for both).
Reverse proxyCaddy (LXC 220)TLS + optional OAuth gateBypasses webhook + static asset routes per [feedback_oauth_gate_vs_crossorigin.md].
Optional add-onsUDM Pro, Cloudflare TunnelEdge networkingFor 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

LayerComponentWhat it owns
EngineLocalSky (Rust)Forecast intelligence, skip/run verdict, 7-day verdict strip, 7-day moisture projection, SSE stream, dashboard, run history, push notifications, LLM advisor
OrchestrationHome AssistantEntity registry, automations, voice integrations, alerting, OAuth surface for the web dashboard
ET bucket mathSmart Irrigation (HACS)FAO-56 daily bucket per zone, ET₀ from Open-Meteo or Tempest, soil/plant type configuration, multiplier overrides
SchedulerIrrigation Unlimited (HACS)Sequence definition, sun-relative trigger, per-zone preamble, manual run + suspend + adjust_time service surface
Hardware bridgeopensprinkler integrationMaps OpenSprinkler stations to HA entities; receives DC-pulse run_seconds commands
Sensor bridgeecowitt2mqttTranslates Ecowitt push payloads (including the soil_ec_* family the native HA integration drops) into MQTT discovery
MQTT brokerEclipse MosquittoLocal broker for discovery + state
Edge proxyCaddyTLS, 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.

DimensionRachio 3LocalSky 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)
Email✅ (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 × threshold for 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_Kc matches Smart Irrigation’s multiplier (1.08 for turf, 0.50 for shrubs),
  • soil_depth_mm is the effective root zone (150 mm turf, 200 mm shrubs),
  • heat_multiplier is 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_pctsaturation_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 mean
  • sensor.<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:

  1. 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").
  2. LocalSky skip_check.reason in the snapshot JSON, displayed on the verdict tile.
  3. 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

ItemRachio pathLocalSky path
Controller hardwareRachio 3 16-zone $250OpenSprinkler 16-zone $179
Outdoor enclosure$30 add-on$0 (OpenSprinkler IP65 stock)
Weather stationTempest $339 (paired to Rachio cloud)Tempest $339 (paired to local hub)
Soil sensorsNot available (or 3rd-party IFTTT-only)4× WH52 $30 each + GW1100B $69 = $189
Flow meterRachio Wireless Flow Meter $200-250OpenSprinkler flow sensor input (BYO sensor ~$20-40)
Valve Monitoring$30 one-time unlockFree
Heat Wave BoostSeparately priced premiumFree
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 totalSame + premium per replacementSame (no recurring)
Hidden cost: replace controllerAll premium unlocks lostBring your config and Postgres/SQLite forward
Hidden cost: vendor changes mindHomeKit dropped 2022n/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:

InputMeaningPer-zone or global
bucket_mmSoil-water deficit. SI’s FAO-56 bucket; negative when ET has depleted from field capacity.Per zone
throughput_mm_hrThe 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)
multiplierCrop coefficient (Kc). St. Augustine warm-season turf runs Kc 1.08 in summer; mulched shrubs ~0.50.Per zone
maximum_durationA 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:

ZoneWeekly budgetSessions/wkNotes
Turf (3 zones)1.0“2UF/IFAS warm-season guideline
Shrubs (mulched)0.5“1Mulch 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

PhaseStatusWhat it adds
AdoneForecast intelligence engine (14 rules)
BdoneHA reads LocalSky verdict via REST sensor
CdoneHeat-stress Kc bump into Smart Irrigation
DdoneEcowitt GW1100B + 4× WH52 via ecowitt2mqtt sidecar with HA-side calibration
EdoneSoil-aware verdict (frost + yard-wide saturation) + per-zone IU.adjust_time overrides + 7-day moisture projection + dashboard tiles
Fdone (degraded)LLM advisor (the LLM provider); offline when vLLM unreachable
PWAdoneManifest, service worker, web push, mobile shell
E.1done“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
HdoneWeekly 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_TOKEN from the HA UI; mint a new one and store it only in aperturelabs-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 as GHCR_PAT in the private overlay
  • Rotate the OpenSprinkler password MD5 if it has ever been committed
  • Do not reuse the ghp_* PAT documented in aperturelabs-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 --check clean
  • cargo clippy -- -D warnings clean
  • cargo check --features ssr zero warnings
  • cargo check --features hydrate --target wasm32-unknown-unknown zero warnings
  • cargo test --features ssr all passing (157+ as of 2026-05-20)
  • cargo deny check clean
  • No Co-Authored-By: Claude trailer 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
  • Dockerfile uses non-root user (useradd --uid 10001 localsky, USER 10001:10001)
  • docker-compose.yml references ghcr.io/silenthooligan/localsky:latest, no internal registry refs, env via ${VAR} from .env.example
  • Cargo.toml has license = "Apache-2.0", repository = "https://github.com/silenthooligan/localsky", homepage = "https://github.com/silenthooligan/localsky"

Phase 4 - Public repo bootstrap

  • Create silenthooligan/localsky on 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.md contact (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.0 and :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.0 shows User: 10001
  • mdBook docs site builds + deploys to GitHub Pages from the v0.1.0 tag

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.0 boots and serves UI; sparkline + zone math visible; cycling verdicts
  • First-run wizard: no env vars, no /data/localsky.toml -> boots to /setup/welcome on 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_compat synthesizes a ha_service_call controller + ha_passthrough source; existing /data/irrigation.db migrates cleanly (M0001 -> latest); UI identical to v0.1
  • Standalone: only tempest_udp + open_meteo configured (no HA, no MQTT) -> dashboard works; /api/health reports ha: not_configured
  • Standalone with MQTT: mqtt_subscribe source pulls soil moisture from an external broker; sensor values land in sensor_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_evapotranspiration on 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’s inputs_json through engine::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-deploy repo created on internal Gitea with docker-compose.yml, .env, .gitea/workflows/deploy-komodo.yml, README
  • Komodo resource sync targets the new repo
  • Old aperturelabs-localsky repo 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/health stays 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.0 on silenthooligan/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 Tested rows; 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:

  1. The grep sweep at the end of Phase 3 returns any match
  2. Any cargo warning anywhere
  3. Trivy reports any HIGH+ vulnerability
  4. The demo mode boot does not serve a full dashboard with cycling verdicts
  5. The first-run wizard cannot complete without manual TOML editing
  6. 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)