#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiManager.h>
#include <PubSubClient.h>
#include <ArduinoOTA.h>
#include <LittleFS.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPUpdateServer.h>
#define IR_OUT_PIN D2
#define MOTOR_PIN D5
#define FW_VERSION "1.3"
char mqtt_host[40] = "";
char mqtt_port[6] = "1883";
char mqtt_user[32] = "";
char mqtt_pass[32] = "";
char mqtt_base[50] = "window_cleaner";
WiFiClient espClient;
PubSubClient mqtt(espClient);
ESP8266WebServer server(80);
ESP8266HTTPUpdateServer httpUpdater;
String topicCmd;
String topicState;
String topicMotor;
String topicAvailability;
String topicFinishDelaySet;
String topicFinishDelayState;
bool shouldSaveConfig = false;
String robotState = "idle";
bool motorRunning = false;
bool lastMotorRunning = false;
unsigned long motorStopSince = 0;
unsigned long finishDelayMs = 10000;
// ================= RAW COMMANDS =================
const uint16_t raw_up_auto[67] = {
9104,4382,690,440,690,442,690,442,692,440,686,444,686,446,688,444,686,446,
688,1566,692,1566,688,1566,690,1564,688,1568,688,1566,690,1566,690,1564,
690,444,684,1568,690,446,686,444,682,1570,686,448,680,452,684,448,
684,1570,684,450,682,1572,682,1572,682,452,680,1572,682,1574,684,1572,682
};
const uint16_t raw_left_auto[67] = {
9094,4394,680,450,678,454,678,454,678,454,678,454,678,454,678,454,676,456,
678,1576,678,1576,678,1578,680,1576,678,1576,676,1580,678,1578,678,1576,
678,456,674,1578,678,454,676,1576,676,1578,676,458,672,460,672,458,
674,1580,674,458,668,1584,672,460,672,460,672,1582,672,1582,644,1612,644
};
const uint16_t raw_right_auto[67] = {
9042,4442,630,500,630,502,630,502,628,504,628,504,630,502,630,502,630,502,
630,1624,630,1626,630,1626,630,1624,630,1624,630,1624,632,1624,632,1624,
632,502,630,1624,632,1624,632,1624,634,1622,632,500,632,502,630,500,
632,1622,632,500,632,500,632,500,630,500,632,1622,634,1622,632,1622,632
};
const uint16_t raw_water_auto[67] = {
9050,4434,640,490,638,494,638,494,638,494,638,494,638,494,638,494,638,494,
638,1616,640,1616,638,1616,640,1616,638,1616,640,1616,640,1614,640,1614,
638,1616,640,494,638,494,638,494,638,494,638,492,638,494,638,492,
638,492,636,1616,638,1616,638,1616,640,1614,640,1616,640,1614,640,1616,640
};
const uint16_t raw_water_manual[67] = {
9052,4426,644,486,642,488,644,488,642,488,644,488,644,488,644,488,642,488,
644,1608,646,1608,676,1580,646,1608,676,1580,674,1580,676,1578,676,1578,
676,456,678,1574,676,458,676,454,678,454,676,456,678,454,674,456,
678,1574,680,452,676,1576,680,1574,680,1574,680,1574,680,1574,678,1576,678
};
const uint16_t raw_up_manual[67] = {
9022,4460,576,554,576,556,576,556,576,556,576,556,576,556,576,556,576,554,
576,1678,578,1678,578,1678,578,1678,578,1678,578,1678,578,1678,578,1678,
576,1678,578,554,576,1678,578,554,578,556,576,554,578,554,576,554,
660,472,576,1676,662,470,660,1594,660,1594,660,1594,578,1678,578,1676,660
};
const uint16_t raw_left_manual[67] = {
9048,4434,638,492,638,494,638,494,636,494,638,494,636,494,638,494,638,494,
638,1614,642,1612,644,1612,644,1610,644,1612,644,1610,648,1608,646,1608,
648,1608,646,1608,676,1578,678,456,646,484,676,456,676,456,676,454,
676,456,674,456,676,456,678,1574,678,1576,678,1576,680,1574,680,1576,680
};
const uint16_t raw_right_manual[67] = {
9040,4438,632,498,632,500,630,500,630,500,630,500,630,502,630,500,632,498,
630,1622,634,1620,634,1620,636,1620,636,1620,636,1616,636,1618,636,1618,
638,1616,638,494,636,496,636,1616,638,496,634,496,636,494,664,466,
636,494,666,1588,638,1616,666,468,636,1618,668,1588,666,1588,670,1584,670
};
const uint16_t raw_down_manual[67] = {
9100,4378,694,438,690,442,690,440,688,442,690,440,690,440,688,444,688,442,
688,1564,692,1562,692,1564,692,1564,690,1564,692,1562,692,1564,692,1562,
692,1564,690,1564,690,444,686,1566,688,1568,690,442,684,448,686,444,
682,448,684,448,682,1568,686,446,684,446,682,1570,684,1570,686,1568,684
};
const uint16_t raw_pause_play[67] = {
9038,4440,628,504,626,504,626,504,628,502,628,504,628,504,626,504,628,504,
626,1626,628,1626,628,1626,628,1626,630,1624,628,1626,630,1624,630,1624,
628,504,626,504,628,502,628,1624,630,502,628,504,630,502,628,504,
626,1626,630,1624,630,1624,630,502,630,1624,630,1624,628,1626,628,1626,630
};
void saveConfigCallback() {
shouldSaveConfig = true;
}
void saveConfig() {
File f = LittleFS.open("/config.txt", "w");
if (!f) return;
f.println(mqtt_host);
f.println(mqtt_port);
f.println(mqtt_user);
f.println(mqtt_pass);
f.println(mqtt_base);
f.println(finishDelayMs);
f.close();
}
void loadConfig() {
if (!LittleFS.exists("/config.txt")) return;
File f = LittleFS.open("/config.txt", "r");
if (!f) return;
String s;
s = f.readStringUntil('\n'); s.trim(); s.toCharArray(mqtt_host, sizeof(mqtt_host));
s = f.readStringUntil('\n'); s.trim(); s.toCharArray(mqtt_port, sizeof(mqtt_port));
s = f.readStringUntil('\n'); s.trim(); s.toCharArray(mqtt_user, sizeof(mqtt_user));
s = f.readStringUntil('\n'); s.trim(); s.toCharArray(mqtt_pass, sizeof(mqtt_pass));
s = f.readStringUntil('\n'); s.trim(); s.toCharArray(mqtt_base, sizeof(mqtt_base));
s = f.readStringUntil('\n');
s.trim();
if (s.length() > 0) finishDelayMs = s.toInt();
if (finishDelayMs < 1000 || finishDelayMs > 600000) finishDelayMs = 10000;
f.close();
}
void publishState() {
if (!mqtt.connected()) return;
mqtt.publish(topicState.c_str(), robotState.c_str(), true);
mqtt.publish(topicMotor.c_str(), motorRunning ? "ON" : "OFF", true);
mqtt.publish(topicFinishDelayState.c_str(), String(finishDelayMs / 1000).c_str(), true);
}
void setRobotState(String newState) {
if (robotState == newState) return;
robotState = newState;
publishState();
}
void setFinishDelaySeconds(int sec) {
if (sec < 1) sec = 1;
if (sec > 600) sec = 600;
finishDelayMs = sec * 1000UL;
saveConfig();
publishState();
}
// ================= IR =================
void sendRawToOut(const uint16_t *data, uint16_t len) {
noInterrupts();
for (uint16_t i = 0; i < len; i++) {
digitalWrite(IR_OUT_PIN, (i & 1) ? LOW : HIGH);
delayMicroseconds(data[i]);
}
digitalWrite(IR_OUT_PIN, LOW);
interrupts();
delay(80);
}
bool sendCommand(String cmd) {
cmd.trim();
if (cmd == "up_auto") {
sendRawToOut(raw_up_auto, 67);
setRobotState("cleaning");
} else if (cmd == "left_auto") {
sendRawToOut(raw_left_auto, 67);
setRobotState("cleaning");
} else if (cmd == "right_auto") {
sendRawToOut(raw_right_auto, 67);
setRobotState("cleaning");
} else if (cmd == "water_auto") {
sendRawToOut(raw_water_auto, 67);
} else if (cmd == "water_manual") {
sendRawToOut(raw_water_manual, 67);
} else if (cmd == "up_manual") {
sendRawToOut(raw_up_manual, 67);
setRobotState("cleaning");
} else if (cmd == "left_manual") {
sendRawToOut(raw_left_manual, 67);
setRobotState("cleaning");
} else if (cmd == "right_manual") {
sendRawToOut(raw_right_manual, 67);
setRobotState("cleaning");
} else if (cmd == "down_manual") {
sendRawToOut(raw_down_manual, 67);
setRobotState("cleaning");
} else if (cmd == "pause") {
sendRawToOut(raw_pause_play, 67);
} else {
return false;
}
publishState();
return true;
}
void updateMotorStatus() {
motorRunning = (digitalRead(MOTOR_PIN) == LOW);
if (motorRunning != lastMotorRunning) {
lastMotorRunning = motorRunning;
publishState();
if (motorRunning) {
motorStopSince = 0;
if (robotState != "cleaning") setRobotState("cleaning");
} else {
motorStopSince = millis();
}
}
if (robotState == "cleaning" && !motorRunning && motorStopSince > 0) {
if (millis() - motorStopSince > finishDelayMs) {
setRobotState("finished");
motorStopSince = 0;
}
}
}
void publishButtonDiscovery(const char* id, const char* name, const char* payload, const char* icon) {
String chip = String(ESP.getChipId(), HEX);
String configTopic = "homeassistant/button/window_cleaner_" + String(id) + "/config";
String json = "{";
json += "\"name\":\"" + String(name) + "\",";
json += "\"unique_id\":\"window_cleaner_" + chip + "_" + String(id) + "\",";
json += "\"command_topic\":\"" + topicCmd + "\",";
json += "\"payload_press\":\"" + String(payload) + "\",";
json += "\"icon\":\"" + String(icon) + "\",";
json += "\"availability_topic\":\"" + topicAvailability + "\",";
json += "\"device\":{\"identifiers\":[\"window_cleaner_" + chip + "\"],";
json += "\"name\":\"Робот мойщик окон\",";
json += "\"manufacturer\":\"DIY\",";
json += "\"model\":\"WeMos D1 mini IR OUT\",";
json += "\"sw_version\":\"" FW_VERSION "\"}}";
mqtt.publish(configTopic.c_str(), json.c_str(), true);
}
void publishSensorDiscovery() {
String chip = String(ESP.getChipId(), HEX);
String stateConfig = "homeassistant/sensor/window_cleaner_state/config";
String stateJson = "{";
stateJson += "\"name\":\"Состояние\",";
stateJson += "\"unique_id\":\"window_cleaner_" + chip + "_state\",";
stateJson += "\"state_topic\":\"" + topicState + "\",";
stateJson += "\"icon\":\"mdi:robot-vacuum\",";
stateJson += "\"availability_topic\":\"" + topicAvailability + "\",";
stateJson += "\"device\":{\"identifiers\":[\"window_cleaner_" + chip + "\"],";
stateJson += "\"name\":\"Робот мойщик окон\",";
stateJson += "\"manufacturer\":\"DIY\",";
stateJson += "\"model\":\"WeMos D1 mini IR OUT\",";
stateJson += "\"sw_version\":\"" FW_VERSION "\"}}";
mqtt.publish(stateConfig.c_str(), stateJson.c_str(), true);
String motorConfig = "homeassistant/binary_sensor/window_cleaner_motor/config";
String motorJson = "{";
motorJson += "\"name\":\"Мотор работает\",";
motorJson += "\"unique_id\":\"window_cleaner_" + chip + "_motor\",";
motorJson += "\"state_topic\":\"" + topicMotor + "\",";
motorJson += "\"payload_on\":\"ON\",";
motorJson += "\"payload_off\":\"OFF\",";
motorJson += "\"device_class\":\"running\",";
motorJson += "\"availability_topic\":\"" + topicAvailability + "\",";
motorJson += "\"device\":{\"identifiers\":[\"window_cleaner_" + chip + "\"],";
motorJson += "\"name\":\"Робот мойщик окон\",";
motorJson += "\"manufacturer\":\"DIY\",";
motorJson += "\"model\":\"WeMos D1 mini IR OUT\",";
motorJson += "\"sw_version\":\"" FW_VERSION "\"}}";
mqtt.publish(motorConfig.c_str(), motorJson.c_str(), true);
String delayConfig = "homeassistant/number/window_cleaner_finish_delay/config";
String delayJson = "{";
delayJson += "\"name\":\"Задержка завершения\",";
delayJson += "\"unique_id\":\"window_cleaner_" + chip + "_finish_delay\",";
delayJson += "\"command_topic\":\"" + topicFinishDelaySet + "\",";
delayJson += "\"state_topic\":\"" + topicFinishDelayState + "\",";
delayJson += "\"min\":1,";
delayJson += "\"max\":600,";
delayJson += "\"step\":1,";
delayJson += "\"unit_of_measurement\":\"s\",";
delayJson += "\"icon\":\"mdi:timer-outline\",";
delayJson += "\"availability_topic\":\"" + topicAvailability + "\",";
delayJson += "\"device\":{\"identifiers\":[\"window_cleaner_" + chip + "\"],";
delayJson += "\"name\":\"Робот мойщик окон\",";
delayJson += "\"manufacturer\":\"DIY\",";
delayJson += "\"model\":\"WeMos D1 mini IR OUT\",";
delayJson += "\"sw_version\":\"" FW_VERSION "\"}}";
mqtt.publish(delayConfig.c_str(), delayJson.c_str(), true);
}
void publishDiscovery() {
publishButtonDiscovery("up_auto", "UP Auto", "up_auto", "mdi:arrow-up-bold");
publishButtonDiscovery("left_auto", "Left Auto", "left_auto", "mdi:arrow-left-bold");
publishButtonDiscovery("right_auto", "Right Auto", "right_auto", "mdi:arrow-right-bold");
publishButtonDiscovery("water_auto", "Water Auto", "water_auto", "mdi:water");
publishButtonDiscovery("water_manual", "Water Manual", "water_manual", "mdi:water-outline");
publishButtonDiscovery("up_manual", "Вверх", "up_manual", "mdi:arrow-up-bold-circle");
publishButtonDiscovery("left_manual", "Влево", "left_manual", "mdi:arrow-left-bold-circle");
publishButtonDiscovery("right_manual", "Вправо", "right_manual", "mdi:arrow-right-bold-circle");
publishButtonDiscovery("down_manual", "Вниз", "down_manual", "mdi:arrow-down-bold-circle");
publishButtonDiscovery("pause", "Пауза / Старт", "pause", "mdi:play-pause");
publishSensorDiscovery();
}
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
msg.trim();
String t = String(topic);
if (t == topicCmd) {
sendCommand(msg);
} else if (t == topicFinishDelaySet) {
setFinishDelaySeconds(msg.toInt());
}
}
void reconnectMqtt() {
if (mqtt.connected()) return;
if (strlen(mqtt_host) == 0) return;
String clientId = "wemos_window_cleaner_";
clientId += String(ESP.getChipId(), HEX);
bool ok;
if (strlen(mqtt_user) > 0) {
ok = mqtt.connect(clientId.c_str(), mqtt_user, mqtt_pass,
topicAvailability.c_str(), 0, true, "offline");
} else {
ok = mqtt.connect(clientId.c_str(),
topicAvailability.c_str(), 0, true, "offline");
}
if (ok) {
mqtt.publish(topicAvailability.c_str(), "online", true);
mqtt.subscribe(topicCmd.c_str());
mqtt.subscribe(topicFinishDelaySet.c_str());
publishDiscovery();
publishState();
}
}
String pageHeader(String title) {
return String(
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>") + title + "</title>"
"<style>"
"body{font-family:Arial;background:#111;color:#eee;margin:0;padding:16px}"
".card{max-width:520px;margin:auto;background:#1b1b1b;padding:16px;border-radius:14px}"
"a.btn,button{display:block;width:100%;box-sizing:border-box;margin:8px 0;padding:14px;border:0;border-radius:10px;background:#2d7ef7;color:white;font-size:18px;text-align:center;text-decoration:none}"
".grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin:12px 0}"
".grid a.btn{margin:0}"
"input{width:100%;box-sizing:border-box;padding:10px;margin:6px 0 12px;border-radius:8px;border:0}"
".muted{color:#aaa;font-size:14px}"
".ok{color:#7CFC00}"
"</style></head><body><div class='card'><h2>" + title + "</h2>";
}
String pageFooter() {
return "</div></body></html>";
}
void handleRoot() {
String html = pageHeader("Робот мойщик окон");
html += "<p class='muted'>IP: " + WiFi.localIP().toString() + "</p>";
html += "<p>State: <b>" + robotState + "</b></p>";
html += "<p>Motor: <b>" + String(motorRunning ? "RUN" : "STOP") + "</b></p>";
html += "<p>Finish delay: <b>" + String(finishDelayMs / 1000) + " сек</b></p>";
html += "<a class='btn' href='/cmd?c=up_auto'>UP Auto</a>";
html += "<a class='btn' href='/cmd?c=left_auto'>Left Auto</a>";
html += "<a class='btn' href='/cmd?c=right_auto'>Right Auto</a>";
html += "<a class='btn' href='/cmd?c=water_auto'>Water Auto</a>";
html += "<a class='btn' href='/cmd?c=water_manual'>Water Manual</a>";
html += "<div class='grid'>";
html += "<div></div><a class='btn' href='/cmd?c=up_manual'>▲</a><div></div>";
html += "<a class='btn' href='/cmd?c=left_manual'>◀</a>";
html += "<a class='btn' href='/cmd?c=pause'>⏯</a>";
html += "<a class='btn' href='/cmd?c=right_manual'>▶</a>";
html += "<div></div><a class='btn' href='/cmd?c=down_manual'>▼</a><div></div>";
html += "</div>";
html += "<a class='btn' href='/settings'>Настройки</a>";
html += "<a class='btn' href='/update'>OTA через браузер</a>";
html += "<a class='btn' href='/resetwifi'>Сброс Wi-Fi/MQTT</a>";
html += pageFooter();
server.send(200, "text/html; charset=utf-8", html);
}
void handleCmd() {
if (!server.hasArg("c")) {
server.send(400, "text/plain; charset=utf-8", "Нет команды");
return;
}
String cmd = server.arg("c");
bool ok = sendCommand(cmd);
String html = pageHeader("Команда");
html += ok ? "<p class='ok'>Отправлено: <b>" + cmd + "</b></p>"
: "<p>Ошибка: <b>" + cmd + "</b></p>";
html += "<a class='btn' href='/'>Назад</a>";
html += pageFooter();
server.send(200, "text/html; charset=utf-8", html);
}
void handleSettings() {
String html = pageHeader("Настройки");
html += "<form action='/save' method='post'>";
html += "MQTT host:<input name='host' value='" + String(mqtt_host) + "'>";
html += "MQTT port:<input name='port' value='" + String(mqtt_port) + "'>";
html += "MQTT user:<input name='user' value='" + String(mqtt_user) + "'>";
html += "MQTT password:<input name='pass' value='" + String(mqtt_pass) + "' type='password'>";
html += "MQTT base topic:<input name='base' value='" + String(mqtt_base) + "'>";
html += "Задержка завершения, сек:<input name='finish_delay' value='" + String(finishDelayMs / 1000) + "'>";
html += "<button type='submit'>Сохранить и перезагрузить</button>";
html += "</form>";
html += "<a class='btn' href='/'>Назад</a>";
html += pageFooter();
server.send(200, "text/html; charset=utf-8", html);
}
void handleSave() {
server.arg("host").toCharArray(mqtt_host, sizeof(mqtt_host));
server.arg("port").toCharArray(mqtt_port, sizeof(mqtt_port));
server.arg("user").toCharArray(mqtt_user, sizeof(mqtt_user));
server.arg("pass").toCharArray(mqtt_pass, sizeof(mqtt_pass));
server.arg("base").toCharArray(mqtt_base, sizeof(mqtt_base));
if (server.hasArg("finish_delay")) {
setFinishDelaySeconds(server.arg("finish_delay").toInt());
}
saveConfig();
server.send(200, "text/html; charset=utf-8", "<meta charset='utf-8'>Сохранено. Перезагрузка...");
delay(1000);
ESP.restart();
}
void handleResetWifi() {
WiFiManager wm;
wm.resetSettings();
LittleFS.remove("/config.txt");
server.send(200, "text/html; charset=utf-8", "<meta charset='utf-8'>Настройки сброшены. Перезагрузка...");
delay(1000);
ESP.restart();
}
void setupWeb() {
server.on("/", handleRoot);
server.on("/cmd", handleCmd);
server.on("/settings", handleSettings);
server.on("/save", HTTP_POST, handleSave);
server.on("/resetwifi", handleResetWifi);
httpUpdater.setup(&server, "/update");
server.begin();
}
void setup() {
Serial.begin(115200);
delay(300);
pinMode(IR_OUT_PIN, OUTPUT);
digitalWrite(IR_OUT_PIN, LOW);
pinMode(MOTOR_PIN, INPUT_PULLUP);
LittleFS.begin();
loadConfig();
WiFiManager wm;
wm.setSaveConfigCallback(saveConfigCallback);
wm.setConfigPortalTimeout(180);
WiFiManagerParameter custom_mqtt_host("host", "MQTT host", mqtt_host, 40);
WiFiManagerParameter custom_mqtt_port("port", "MQTT port", mqtt_port, 6);
WiFiManagerParameter custom_mqtt_user("user", "MQTT user", mqtt_user, 32);
WiFiManagerParameter custom_mqtt_pass("pass", "MQTT password", mqtt_pass, 32);
WiFiManagerParameter custom_mqtt_base("base", "MQTT base topic", mqtt_base, 50);
wm.addParameter(&custom_mqtt_host);
wm.addParameter(&custom_mqtt_port);
wm.addParameter(&custom_mqtt_user);
wm.addParameter(&custom_mqtt_pass);
wm.addParameter(&custom_mqtt_base);
bool res = wm.autoConnect("WindowCleaner-Setup");
strcpy(mqtt_host, custom_mqtt_host.getValue());
strcpy(mqtt_port, custom_mqtt_port.getValue());
strcpy(mqtt_user, custom_mqtt_user.getValue());
strcpy(mqtt_pass, custom_mqtt_pass.getValue());
strcpy(mqtt_base, custom_mqtt_base.getValue());
if (shouldSaveConfig) saveConfig();
if (!res) ESP.restart();
topicCmd = String(mqtt_base) + "/cmd";
topicState = String(mqtt_base) + "/state";
topicMotor = String(mqtt_base) + "/motor";
topicAvailability = String(mqtt_base) + "/availability";
topicFinishDelaySet = String(mqtt_base) + "/finish_delay/set";
topicFinishDelayState = String(mqtt_base) + "/finish_delay/state";
mqtt.setBufferSize(1024);
mqtt.setServer(mqtt_host, atoi(mqtt_port));
mqtt.setCallback(mqttCallback);
ArduinoOTA.setHostname("window-cleaner");
ArduinoOTA.begin();
setupWeb();
}
void loop() {
ArduinoOTA.handle();
server.handleClient();
if (!mqtt.connected()) reconnectMqtt();
mqtt.loop();
updateMotorStatus();
}
Comments