Skip to content

Instantly share code, notes, and snippets.

@Jxck-S
Last active December 30, 2025 04:54
Show Gist options
  • Select an option

  • Save Jxck-S/8aa672bdba0d5a9883a3084c28d84f0a to your computer and use it in GitHub Desktop.

Select an option

Save Jxck-S/8aa672bdba0d5a9883a3084c28d84f0a to your computer and use it in GitHub Desktop.
ESPHome OpenSprinkler Remote Station Firmware Turn any ESP32/ESP8266 + Relay board into a native OpenSprinkler expansion station with offline fallback and Home Assistant control.

OpenSprinkler ESPHome Remote Station

This project allows you to build a Sprinkler Controller Station using standard ESP32 hardware and ESPHome, which acts as a "Remote Extension" for a primary OpenSprinkler Master instance.

The Master instance runs the scheduling logic (e.g., using my OpenSprinkler Docker container or Home Assistant Add-on), (or any OpenSprinkler Master) while this ESP device physically controls the valves.

Why use this?

  1. Hardware Customizability: Use any ESP board (Ethernet or WiFi) and any standard 5V Relay Module. No proprietary hardware required.
  2. Easy Updates: Use ESPHome's robust OTA (Over-The-Air) update mechanism to maintain your station firmware.
  3. Decoupled Upgrades: Upgrade your Master OpenSprinkler software (in Docker/HA) without needing to re-flash or touch the physical controller.
  4. Resiliency: This firmware syncs the schedule from the Master and stores it in Flash memory. If the network goes down, it will continue to run the schedule using its internal RTC (SNTP synced).

How it Works

  1. Master Controller: Tells this device to "Turn Station 1 ON" via HTTP API.
  2. ESPHome Station: Receives command, turns on relay.
  3. Nightly Sync: The Station downloads the full schedule (/ja) from the Master every 24 hours (or on boot) to prepare for offline fallback.

Setup Instructions

1. Hardware

You need any microcontroller supported by ESPHome (ESP32, ESP8266, RP2040, BK7231, etc.) and a Relay Module.

2. Configuration

  1. Copy sprinkler-controller.yaml and opensprinkler_api.h to your ESPHome config directory.
  2. Edit sprinkler-controller.yaml:
    • Board Config Configure ESP board type, and Connectivity WIFI/Ethernet
    • Master IP: Set the IP address of your OpenSprinkler Master.
    • Password/Key: Generate the MD5 hash of your Master's password (for opensprinkler_api.h)
    • Stations: Update the OpenSprinklerAPI list to match your station IDs.
    • Switches: For each station, you must define:
      1. GPIO Driver (switch.gpio): The physical pin control (internal).
      2. Template Switch (switch.template): The public interface.
    • Interlock Logic: In every Template Switch, update the lambda condition to check all other drivers.
      • Example: If adding Station 3, update Station 1 & 2's lambda to also check id(station3_driver).state.
    • Inverted Logic: Set inverted: true if using standard Active Low relays; remove it if using High Trigger relays.

3. Flash

Compile and upload to your ESP using ESPHome.

Usage

In your OpenSprinkler Master:

  1. Go to Stations.
  2. Add a new station.
  3. Set type to Remote.
  4. Enter the IP Address of this ESP.
  5. It should connect and control the valves!

Note on Home Assistant: This setup should works seamlessly with the hass-opensprinkler integration. The data flow is: HA Integration -> Master -> Remote ESP Station. Home Assistant will see and control these valves through the Master

#pragma once
#include "esphome.h"
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <vector>
#include <time.h>
struct Program {
uint32_t flags;
uint32_t days0;
uint32_t days1;
std::vector<int> start_times; // minutes from midnight
std::vector<int> durations; // seconds, index maps to station
std::string name;
};
// Global pointer for manual loop access
using namespace esphome;
class OpenSprinklerAPI;
OpenSprinklerAPI *global_os_api = nullptr;
class OpenSprinklerAPI : public Component {
public:
OpenSprinklerAPI(std::vector<esphome::switch_::Switch *> switches, std::string expected_password_md5, std::string master_ip, uint16_t master_port = 80)
: switches_(switches), expected_password_md5_(expected_password_md5), master_port_(master_port) {
if (master_ip.length() > 0) {
this->master_ip_.fromString(master_ip.c_str());
}
global_os_api = this;
}
std::string expected_password_md5_;
uint16_t master_port_;
::AsyncWebServer *server_;
std::vector<esphome::switch_::Switch *> switches_;
Preferences prefs_; // Flash storage
// Storage for synced programs
std::vector<Program> programs_;
IPAddress master_ip_ = IPAddress(0,0,0,0);
volatile bool check_master_pending_ = false;
int pending_station_ = -1;
int pending_duration_ = 0;
unsigned long last_master_check_ = 0;
bool is_offline_ = false;
int offline_failure_count_ = 0;
int last_run_minute_ = -1;
void setup() override {
ESP_LOGI("opensprinkler_api", "Setting up OpenSprinkler API with Hardcoded Master IP: %s", this->master_ip_.toString().c_str());
this->prefs_.begin("os_api", false);
this->load_state();
this->server_ = new ::AsyncWebServer(80);
// Handler for /cv (Change Valve)
this->server_->on("/cv", HTTP_ANY, [this](::AsyncWebServerRequest *request) {
this->handle_control_request(request, "/cv");
});
// Handler for /cm (Change Manual)
this->server_->on("/cm", HTTP_ANY, [this](::AsyncWebServerRequest *request) {
this->handle_control_request(request, "/cm");
});
// /jo: Get Options (JSON)
this->server_->on("/jo", HTTP_ANY, [this](::AsyncWebServerRequest *request) {
ESP_LOGI("opensprinkler_api", "Received /jo %s from %s", request->methodToString(), request->client()->remoteIP().toString().c_str());
if (!request->hasParam("pw")) {
request->send(401, "application/json", "{\"result\": 2}");
return;
}
String pw = request->getParam("pw")->value();
if (pw.c_str() != this->expected_password_md5_) {
ESP_LOGW("opensprinkler_api", "Auth Failed in /jo! Received: %s", pw.c_str());
request->send(401, "application/json", "{\"result\": 2}");
return;
}
::AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["ver"] = "2.1.9";
doc["fwv"] = 219;
doc["nbrd"] = 1;
::time_t now;
::time(&now);
doc["devt"] = (long)now;
doc["tz"] = 0;
doc["en"] = 1;
doc["rd"] = 0;
doc["rs"] = 0;
doc["rdst"] = 0;
doc["wl"] = 100;
doc["re"] = 1; // Remote Extender Mode
doc["mas"] = 0;
doc["result"] = 1;
serializeJson(doc, *response);
request->send(response);
});
this->server_->on("/jn", HTTP_ANY, [&](::AsyncWebServerRequest *request) {
ESP_LOGI("opensprinkler_api", "Received /jn %s from %s", request->methodToString(), request->client()->remoteIP().toString().c_str());
::AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["ver"] = "2.1.9";
JsonArray sbits = doc["sbits"].to<JsonArray>();
int num_bytes = (this->switches_.size() + 7) / 8;
if (num_bytes == 0) num_bytes = 1;
for (int i = 0; i < num_bytes; i++) {
int byte_val = 0;
for (int b = 0; b < 8; b++) {
int station_idx = i * 8 + b;
if (station_idx < this->switches_.size()) {
if (this->switches_[station_idx]->state) byte_val |= (1 << b);
}
}
sbits.add(byte_val);
}
doc["result"] = 1;
serializeJson(doc, *response);
request->send(response);
});
this->server_->onNotFound([](::AsyncWebServerRequest *request){
request->send(404);
});
this->server_->begin();
}
bool setup_done_ = false;
void loop() override {
if (!this->setup_done_) {
this->setup();
this->setup_done_ = true;
}
unsigned long now = millis();
// 0. Delayed Status Log (for user visibility)
static bool status_logged = false;
if (!status_logged && now > 5000) {
if (this->programs_.empty()) {
ESP_LOGW("opensprinkler_api", "Startup Check: No programs in memory (Persistence failed or empty?)");
} else {
ESP_LOGI("opensprinkler_api", "Startup Check: Running with %d programs loaded.", this->programs_.size());
}
status_logged = true;
}
// 1. Periodic Program Sync (Every 24h)
static unsigned long last_sync = 0;
static bool initial_sync_done = false;
if (this->master_ip_ != IPAddress(0,0,0,0)) {
if (!initial_sync_done && now > 30000) {
this->sync_programs_from_master();
initial_sync_done = true;
last_sync = now;
}
// 24 hours = 86400000 ms
if (now - last_sync > 86400000) {
this->sync_programs_from_master();
last_sync = now;
}
}
// 2. Periodic Offline Check (every 60s)
if (now - this->last_master_check_ > 60000) {
this->last_master_check_ = now;
this->check_master_health();
}
// 3. Fallback Playback (only if offline)
if (this->is_offline_) {
this->run_fallback_schedule();
}
}
protected:
void handle_control_request(::AsyncWebServerRequest *request, const char* endpoint_name) {
String sid_str = request->hasParam("sid") ? request->getParam("sid")->value() : "N/A";
String en_str = request->hasParam("en") ? request->getParam("en")->value() : "N/A";
String t_str = request->hasParam("t") ? request->getParam("t")->value() : "0";
String bl_str = request->hasParam("bl") ? request->getParam("bl")->value() : "0";
String pw_state = request->hasParam("pw") ? "PROVIDED" : "MISSING";
ESP_LOGI("opensprinkler_api", "Received %s %s from %s [sid=%s en=%s t=%s bl=%s pw=%s]",
endpoint_name, request->methodToString(), request->client()->remoteIP().toString().c_str(),
sid_str.c_str(), en_str.c_str(), t_str.c_str(), bl_str.c_str(), pw_state.c_str());
if (!request->hasParam("pw") || !request->hasParam("sid") || !request->hasParam("en")) {
request->send(400, "application/json", "{\"result\": 2}");
return;
}
String pw = request->getParam("pw")->value();
if (pw.c_str() != this->expected_password_md5_) {
ESP_LOGW("opensprinkler_api", "Auth Failed in %s! Received: %s, Expected: %s", endpoint_name, pw.c_str(), this->expected_password_md5_.c_str());
request->send(401, "application/json", "{\"result\": 2}");
return;
}
int sid = sid_str.toInt();
int en = en_str.toInt();
if (sid < 0 || sid >= this->switches_.size()) {
request->send(400, "application/json", "{\"result\": 17}");
return;
}
if (en) {
this->switches_[sid]->turn_on();
// Note: We no longer trigger learning on turn_on.
// We rely on nightly sync.
} else {
this->switches_[sid]->turn_off();
}
request->send(200, "application/json", "{\"result\": 1}");
}
void sync_programs_from_master() {
ESP_LOGI("opensprinkler_api", "Sync: Synchronizing programs from Master...");
HTTPClient http;
String url = "http://" + this->master_ip_.toString() + ":" + String(this->master_port_) + "/ja?pw=" + String(this->expected_password_md5_.c_str());
http.begin(url);
int httpCode = http.GET();
if (httpCode == 200) {
String payload = http.getString();
JsonDocument doc;
// Increase capacity if needed, /ja is large
DeserializationError error = deserializeJson(doc, payload);
if (error) {
ESP_LOGW("opensprinkler_api", "Sync: Failed to parse JSON from Master: %s", error.c_str());
http.end();
return;
}
JsonArray pd = doc["programs"]["pd"];
std::vector<Program> new_programs;
for (JsonArray prog : pd) {
Program p;
p.flags = prog[0];
p.days0 = prog[1];
p.days1 = prog[2];
JsonArray starts = prog[3];
for(int s : starts) p.start_times.push_back(s);
JsonArray durs = prog[4];
int d_idx = 0;
for(int d : durs) {
// Only store durations for stations we actually have
if (d_idx < this->switches_.size()) {
p.durations.push_back(d);
}
d_idx++;
}
p.name = prog[5].as<std::string>();
new_programs.push_back(p);
}
this->programs_ = new_programs;
this->save_state();
ESP_LOGI("opensprinkler_api", "Sync: Synced %d programs from Master.", new_programs.size());
this->is_offline_ = false;
this->offline_failure_count_ = 0;
} else {
ESP_LOGW("opensprinkler_api", "Sync: Failed to query Master: %d", httpCode);
}
http.end();
}
void check_master_health() {
if (this->master_ip_ == IPAddress(0,0,0,0)) return;
HTTPClient http;
String url = "http://" + this->master_ip_.toString() + ":" + String(this->master_port_) + "/jo?pw=" + String(this->expected_password_md5_.c_str());
http.begin(url);
int httpCode = http.GET();
http.end();
if (httpCode == 200) {
this->is_offline_ = false;
this->offline_failure_count_ = 0;
} else {
this->offline_failure_count_++;
if (this->offline_failure_count_ > 3) {
this->is_offline_ = true;
ESP_LOGW("opensprinkler_api", "Master is unreachable (%d failures). Entering FALLBACK MODE.", this->offline_failure_count_);
}
}
}
// --- Helper Logic for Program Validation ---
// Check if program runs today (Native OS Logic)
bool is_program_active_today(const Program& p, struct tm* timeinfo, time_t now) {
// 1. Check Enabled (Bit 0)
if (!(p.flags & 1)) return false;
// 2. Check Date Range (if enabled, Bit 7)
// (Skipping for now as per plan, assuming always valid or handled by Master sanitization)
// 3. Check Odd/Even Restriction (Bits 2-3)
int oddeven = (p.flags >> 2) & 0x03;
int day_of_month = timeinfo->tm_mday; // 1-31
if (oddeven == 1) { // Odd Days
// Skip 31st and Feb 29th
if (day_of_month == 31) return false;
if (timeinfo->tm_mon == 1 && day_of_month == 29) return false;
if (day_of_month % 2 == 0) return false;
} else if (oddeven == 2) { // Even Days
if (day_of_month % 2 != 0) return false;
}
// 4. Check Program Type (Bits 4-5)
int type = (p.flags >> 4) & 0x03;
if (type == 0) { // WEEKLY
// Bit 0 = Monday ... Bit 6 = Sunday
// tm_wday: 0 = Sun ... 6 = Sat
// Map: Sun(0)->6, Mon(1)->0, Tue(2)->1...
int os_day_idx = (timeinfo->tm_wday + 6) % 7;
if (!(p.days0 & (1 << os_day_idx))) return false;
} else if (type == 3) { // INTERVAL
// OpenSprinkler Logic: (epoch_day % interval) == remainder
// days1 = interval, days0 = remainder
long epoch_day = now / 86400; // Standard integer division
int interval = p.days1;
int remainder = p.days0;
if (interval == 0) return false; // Safety
if ((epoch_day % interval) != remainder) return false;
} else {
// Unhandled Type (Single Run etc), skip for safety
return false;
}
return true;
}
// Get list of start times in minutes (Native OS Logic)
std::vector<int> get_program_start_times(const Program& p) {
std::vector<int> times;
// Check Start Time Type (Bit 6)
int st_type = (p.flags >> 6) & 0x01;
if (st_type == 1) { // FIXED Start Times
// Just return the list as is
return p.start_times;
} else { // REPEATING Start Times
// start_times vector map: [0]=start, [1]=count, [2]=interval
if (p.start_times.size() < 3) return times;
int start_min = p.start_times[0];
int count = p.start_times[1];
int interval_min = p.start_times[2];
// Generate list
// Note: OS firmware uses count-1 loop if it's 0-indexed, but protocol says count is "number of repeats" or "number of times"?
// Looking at decoded JSON: "repeats: 0" means runs ONCE.
// If count is 0, we run at least once (the start time).
// Logic: start, start+int, start+2*int ...
times.push_back(start_min);
for (int i=0; i < count; i++) {
start_min += interval_min;
times.push_back(start_min); // rollover handled by caller if > 1440? Fallback runs day-by-day.
}
}
return times;
}
void run_fallback_schedule() {
::time_t now;
::time(&now);
struct ::tm * timeinfo = ::localtime(&now);
// 1. Valid Time Check: If NTP hasn't synced (year < 2023), don't run schedule.
if (timeinfo->tm_year < (2023 - 1900)) return;
// Calculate seconds from midnight
long current_seconds = (timeinfo->tm_hour * 3600) + (timeinfo->tm_min * 60) + timeinfo->tm_sec;
for (auto &prog : this->programs_) {
// Check if active today
if (!is_program_active_today(prog, timeinfo, now)) continue;
// Get Expanded Start Times
std::vector<int> starts = get_program_start_times(prog);
// Process each start time
for (int start_min : starts) {
long seq_start_seconds = start_min * 60;
// Sequential Processing
for (int stn_idx = 0; stn_idx < prog.durations.size(); stn_idx++) {
int duration = prog.durations[stn_idx];
if (duration <= 0) continue;
// Helper filtered durations during Sync, so stn_idx is safe
if (stn_idx >= this->switches_.size()) continue;
long start_t = seq_start_seconds;
long end_t = start_t + duration;
// Check events (Check for cross-day wrapping? Simple Check for now)
if (start_t >= 86400) start_t %= 86400;
if (end_t >= 86400) end_t %= 86400;
bool should_be_on = false;
// Interval Logic (Handles wrapping if end_t < start_t)
if (start_t < end_t) {
should_be_on = (current_seconds >= start_t && current_seconds < end_t);
} else { // Wrapped over midnight
should_be_on = (current_seconds >= start_t || current_seconds < end_t);
}
// State Enforcement (Resiliency)
if (should_be_on) {
if (!this->switches_[stn_idx]->state) {
ESP_LOGI("opensprinkler_api", "Fallback: Resuming Program '%s' on Station %d", prog.name.c_str(), stn_idx);
this->switches_[stn_idx]->turn_on();
}
} else if (current_seconds == end_t) {
// Use edge trigger for OFF to allow manual override *outside* of schedule?
// Actually, sticking to edge for OFF is safer to avoid fighting manual OFF during non-schedule times.
if (this->switches_[stn_idx]->state) {
ESP_LOGI("opensprinkler_api", "Fallback: Program '%s' turning OFF Station %d", prog.name.c_str(), stn_idx);
this->switches_[stn_idx]->turn_off();
}
}
// Advance sequence (raw seconds, not wrapped)
seq_start_seconds += duration;
}
}
}
}
void load_state() {
// Load Programs (JSON String)
String prog_json = this->prefs_.getString("prog_json", "");
if (prog_json.length() > 0) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, prog_json);
if (!error) {
JsonArray pd = doc.as<JsonArray>();
this->programs_.clear();
for (JsonArray prog : pd) {
Program p;
p.flags = prog[0];
p.days0 = prog[1];
p.days1 = prog[2];
JsonArray starts = prog[3];
for(int s : starts) p.start_times.push_back(s);
JsonArray durs = prog[4];
for(int d : durs) p.durations.push_back(d);
p.name = prog[5].as<std::string>();
this->programs_.push_back(p);
}
ESP_LOGI("opensprinkler_api", "Persistence: Loaded %d programs", this->programs_.size());
} else {
ESP_LOGW("opensprinkler_api", "Persistence: Failed to parse programs: %s", error.c_str());
}
} else {
ESP_LOGI("opensprinkler_api", "Persistence: No saved existing programs found.");
}
}
void save_state() {
// Save Programs
if (!this->programs_.empty()) {
JsonDocument doc;
JsonArray pd = doc.to<JsonArray>();
for (auto &p : this->programs_) {
JsonArray prog = pd.add<JsonArray>();
prog.add(p.flags);
prog.add(p.days0);
prog.add(p.days1);
JsonArray starts = prog.add<JsonArray>();
for(int s : p.start_times) starts.add(s);
JsonArray durs = prog.add<JsonArray>();
for(int d : p.durations) durs.add(d);
prog.add(p.name);
}
String output;
serializeJson(doc, output);
String current_json = this->prefs_.getString("prog_json", "");
if (output != current_json) {
this->prefs_.putString("prog_json", output);
ESP_LOGI("opensprinkler_api", "Persistence: Saved programs (%d bytes)", output.length());
} else {
ESP_LOGD("opensprinkler_api", "Persistence: Programs already synced...");
}
}
}
};
esphome:
name: sprinkler-controller
friendly_name: Sprinkler Controller
# Include the custom component header
includes:
- opensprinkler_api.h
libraries:
- mathieucarbou/ESPAsyncWebServer @ 3.6.0
- mathieucarbou/AsyncTCP @ 3.3.2
- ArduinoJson
on_boot:
priority: 800 # Start after WiFi/network is likely up
then:
- lambda: |-
// Initialize API with your stations
auto *api_component = new OpenSprinklerAPI(
{id(station1), id(station2)}, // <--- Add all your station IDs here!
"YOUR_MD5_HASHED_PASSWORD_HERE", // <--- REDACTED MD5 HASH SAME as YOUR OS Master
"192.168.1.100", // <--- REDACTED: Replace with Master IP
8080 // <--- REDACTED: Replace with Master Port
);
global_os_api = api_component;
App.register_component(api_component);
# ==========================
# 1. Board Configuration
# ==========================
# Configure your specific board here (ESP32, ESP8266, RP2040, etc.)
# Example for a generic ESP32:
# esp32:
# board: esp32dev
# framework:
# type: arduino
# Example for ESP8266:
# esp8266:
# board: d1_mini
# ==========================
# 2. Network Configuration
# ==========================
# Configure either WiFi or Ethernet below
# wifi:
# ssid: "MySSID"
# password: "MyPassword"
# ethernet:
# type: LAN8720 ... (Check ESPHome docs for your board)
# ==========================
# Core services
# ==========================
interval:
- interval: 2s
then:
- lambda: |-
if (global_os_api != nullptr) {
global_os_api->loop();
}
logger:
api:
encryption:
key: "YOUR_API_ENCRYPTION_KEY_HERE" # <--- REDACTED
time:
- platform: sntp
id: sntp_time
timezone: "America/New_York"
ota:
- platform: esphome
password: "YOUR_OTA_PASSWORD_HERE" # <--- REDACTED
# ==========================
# 3. Sprinkler Stations
# ==========================
script:
# script to stop all drivers (Used for interlock delay)
- id: stop_all_drivers
then:
- switch.turn_off: station1_driver
- switch.turn_off: station2_driver
# Add lines here for every station you have
switch:
# ----------------------------------------------------------------
# DRIVERS: The physical GPIO configurations.
# Hidden from Home Assistant (internal: true).
# ----------------------------------------------------------------
# IMPORTANT: 'inverted: true' is for Active Low relays (ON = GND).
# If you have High Trigger relays, remove 'inverted: true'.
- platform: gpio
pin: GPIO4 # <--- Configure your Pin
id: station1_driver
internal: true
inverted: true # <--- Check your polarity!
restore_mode: ALWAYS_OFF
on_turn_on:
- lambda: "id(station1).publish_state(true);"
on_turn_off:
- lambda: "id(station1).publish_state(false);"
- platform: gpio
pin: GPIO2 # <--- Configure your Pin
id: station2_driver
internal: true
inverted: true
restore_mode: ALWAYS_OFF
on_turn_on:
- lambda: "id(station2).publish_state(true);"
on_turn_off:
- lambda: "id(station2).publish_state(false);"
# Add more Drivers here for Station 3, 4, etc...
# ----------------------------------------------------------------
# PUBLIC SWITCHES: The "Optimistic" Interface
# These are what OpenSprinkler (and HA) will see and control.
# ----------------------------------------------------------------
- platform: template
name: "Station 1"
id: station1
optimistic: true
turn_on_action:
- if:
# Interlock: Check if ANY other station is ON (Add all driver IDs here)
condition:
lambda: "return id(station2_driver).state;"
then:
- script.execute: stop_all_drivers
- delay: 10s
- switch.turn_on: station1_driver
turn_off_action:
- switch.turn_off: station1_driver
- platform: template
name: "Station 2"
id: station2
optimistic: true
turn_on_action:
- if:
condition:
lambda: "return id(station1_driver).state;"
then:
- script.execute: stop_all_drivers
- delay: 10s
- switch.turn_on: station2_driver
turn_off_action:
- switch.turn_off: station2_driver
# Add more Template Switches here for Station 3, 4, etc...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment