|
#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..."); |
|
} |
|
} |
|
} |
|
}; |