Hardware components | ||||||
| × | 1 | ||||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
![]() |
|
HA SwitchPlate
The HA SwitchPlate is a user-programmable LCD touchscreen you can mount into a standard North American work box in place of a light switch. It connects to your home automation system over WiFi to send and receive MQTT messages in response to user interactions on the screen or events happening in your home. The result is an attractive and highly-customizable controller for your home automation system which you can build yourself!
The HA SwitchPlate ("HASP") utilizes a Nextion 2.4" LCD Touchscreen display mounted in a 3D-printed enclosure as a touchscreen panel for home control and information display. An ESP8266-based microcontroller provides WiFi connectivity and system control. The project has been developed to integrate with Home Assistant and OpenHAB but should be compatible with any other MQTT-enabled automation platform such as Domoticz, Node-Red, Wink, SmartThings, Vera, HomeKit, etc.
The Arduino code for the ESP8266 provides a generic gateway between MQTT and the Nextion instruction set. A basic Nextion HMI display file has been included with several pages of various layouts to provide user controls or to present information in response to MQTT messages sent to the device.
Demo screensAs this build requires some specialist skills and tools, I will occasionally be offering assembled devices for sale here.
Buy me a coffeeThis project is powered by coffee. I might get a little weird about it at times, but it's not much of a stretch to suggest that coffee both powers and consumes a fair portion of my mental energy. Hook me up if you think HASP is cool. Thanks!
Bill of MaterialsTo build a simple version of this project you will minimally need the Nextion display and the WeMos D1 Mini, 4 jumper wires, and a USB cable to power both devices.
A complete build that's ready to install will require the following components:
- Nextion 2.4" LCD Touchscreen display
- WeMos D1 Mini ESP8266 WiFi microcontroller
- 3D printed switch plate
- 3D printed rear cover
- Mean Well IRM-03-5 AC to 5VDC Power supply
- PCB
- 2N3904 NPN Transistor
- 1k Ohm Resistor
- 4pin 2.54mm JST-XH PCB header
- Rubber grommet
- 6" each of white and black 300V 18AWG stranded power cables
- Two M2 self-tapping 6MM screws (or just any 4-6mm M2 screws) to mount PCB in rear enclosure
- Four 20mm M2 flathead screws and four 3mm M2 threaded inserts to fasten both halves of the enclosure together
Check out the documentation to get started building your own HA SwitchPlate.
////////////////////////////////////////////////////////////////////////////////////////////////////
// _____ _____ _____ _____
// | | | _ | __| _ |
// | | |__ | __|
// |__|__|__|__|_____|__|
// Home Automation Switch Plate
// https://github.com/aderusha/HASwitchPlate
//
// Copyright (c) 2019 Allen Derusha allen@derusha.org
//
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this hardware,
// software, and associated documentation files (the "Product"), to deal in the Product without
// restriction, including without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Product, and to permit persons to whom the
// Product is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Product.
//
// THE PRODUCT IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
// NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE PRODUCT OR THE USE OR OTHER DEALINGS IN THE PRODUCT.
////////////////////////////////////////////////////////////////////////////////////////////////////
// OPTIONAL: Assign default values here.
char wifiSSID[32] = ""; // Leave unset for wireless autoconfig. Note that these values will be lost
char wifiPass[64] = ""; // when updating, but that's probably OK because they will be saved in EEPROM.
////////////////////////////////////////////////////////////////////////////////////////////////////
// These defaults may be overwritten with values saved by the web interface
char mqttServer[64] = "";
char mqttPort[6] = "1883";
char mqttUser[32] = "";
char mqttPassword[32] = "";
char haspNode[16] = "plate01";
char groupName[16] = "plates";
char configUser[32] = "admin";
char configPassword[32] = "";
char motionPinConfig[3] = "0";
////////////////////////////////////////////////////////////////////////////////////////////////////
#include <FS.h>
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
#include <ArduinoOTA.h>
#include <DNSServer.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266httpUpdate.h>
#include <ESP8266HTTPUpdateServer.h>
#include <WiFiManager.h>
#include <ArduinoJson.h>
#include <MQTT.h>
#include <EEPROM.h>
#include <SoftwareSerial.h>
const float haspVersion = 0.36; // Current HASP software release version
byte nextionReturnBuffer[128]; // Byte array to pass around data coming from the panel
uint8_t nextionReturnIndex = 0; // Index for nextionReturnBuffer
uint8_t nextionActivePage = 0; // Track active LCD page
bool lcdConnected = false; // Set to true when we've heard something from the LCD
char wifiConfigPass[9]; // AP config password, always 8 chars + NUL
char wifiConfigAP[19]; // AP config SSID, haspNode + 3 chars
bool shouldSaveConfig = false; // Flag to save json config to SPIFFS
bool nextionReportPage0 = false; // If false, don't report page 0 sendme
const unsigned long updateCheckInterval = 43200000; // Time in msec between update checks (12 hours)
unsigned long updateCheckTimer = 0; // Timer for update check
const unsigned long nextionCheckInterval = 5000; // Time in msec between nextion connection checks
unsigned long nextionCheckTimer = 0; // Timer for nextion connection checks
unsigned int nextionRetryMax = 5; // Attempt to connect to panel this many times
bool updateEspAvailable = false; // Flag for update check to report new ESP FW version
float updateEspAvailableVersion; // Float to hold the new ESP FW version number
bool updateLcdAvailable = false; // Flag for update check to report new LCD FW version
bool debugSerialEnabled = true; // Enable USB serial debug output
bool debugTelnetEnabled = false; // Enable telnet debug output
bool debugSerialD8Enabled = true; // Enable hardware serial debug output on pin D8
const unsigned long telnetInputMax = 128; // Size of user input buffer for user telnet session
bool motionEnabled = false; // Motion sensor is enabled
bool mdnsEnabled = true; // mDNS enabled
uint8_t motionPin = 0; // GPIO input pin for motion sensor if connected and enabled
bool motionActive = false; // Motion is being detected
const unsigned long motionLatchTimeout = 30000; // Latch time for motion sensor
const unsigned long motionBufferTimeout = 1000; // Latch time for motion sensor
unsigned long lcdVersion = 0; // Int to hold current LCD FW version number
unsigned long updateLcdAvailableVersion; // Int to hold the new LCD FW version number
bool lcdVersionQueryFlag = false; // Flag to set if we've queried lcdVersion
const String lcdVersionQuery = "p[0].b[2].val"; // Object ID for lcdVersion in HMI
bool startupCompleteFlag = false; // Startup process has completed
const long statusUpdateInterval = 300000; // Time in msec between publishing MQTT status updates (5 minutes)
long statusUpdateTimer = 0; // Timer for update check
const unsigned long connectTimeout = 300; // Timeout for WiFi and MQTT connection attempts in seconds
const unsigned long reConnectTimeout = 15; // Timeout for WiFi reconnection attempts in seconds
byte espMac[6]; // Byte array to store our MAC address
const uint16_t mqttMaxPacketSize = 4096; // Size of buffer for incoming MQTT message
String mqttClientId; // Auto-generated MQTT ClientID
String mqttGetSubtopic; // MQTT subtopic for incoming commands requesting .val
String mqttStateTopic; // MQTT topic for outgoing panel interactions
String mqttCommandTopic; // MQTT topic for incoming panel commands
String mqttGroupCommandTopic; // MQTT topic for incoming group panel commands
String mqttStatusTopic; // MQTT topic for publishing device connectivity state
String mqttSensorTopic; // MQTT topic for publishing device information in JSON format
String mqttLightCommandTopic; // MQTT topic for incoming panel backlight on/off commands
String mqttLightStateTopic; // MQTT topic for outgoing panel backlight on/off state
String mqttLightBrightCommandTopic; // MQTT topic for incoming panel backlight dimmer commands
String mqttLightBrightStateTopic; // MQTT topic for outgoing panel backlight dimmer state
String mqttMotionStateTopic; // MQTT topic for outgoing motion sensor state
String nextionModel; // Record reported model number of LCD panel
const byte nextionSuffix[] = {0xFF, 0xFF, 0xFF}; // Standard suffix for Nextion commands
long tftFileSize = 0; // Filesize for TFT firmware upload
uint8_t nextionResetPin = D6; // Pin for Nextion power rail switch (GPIO12/D6)
WiFiClient wifiClient;
MQTTClient mqttClient(mqttMaxPacketSize);
ESP8266WebServer webServer(80);
ESP8266HTTPUpdateServer httpOTAUpdate;
WiFiServer telnetServer(23);
WiFiClient telnetClient;
// Additional CSS style to match Hass theme
const char HASP_STYLE[] = "<style>button{background-color:#03A9F4;}body{width:60%;margin:auto;}input:invalid{border:1px solid red;}input[type=checkbox]{width:20px;}</style>";
// URL for auto-update "version.json"
const char UPDATE_URL[] = "http://haswitchplate.com/update/version.json";
// Default link to compiled Arduino firmware image
String espFirmwareUrl = "http://haswitchplate.com/update/HASwitchPlate.ino.d1_mini.bin";
// Default link to compiled Nextion firmware images
String lcdFirmwareUrl = "http://haswitchplate.com/update/HASwitchPlate.tft";
////////////////////////////////////////////////////////////////////////////////////////////////////
void setup()
{ // System setup
pinMode(nextionResetPin, OUTPUT);
digitalWrite(nextionResetPin, HIGH);
Serial.begin(115200); // Serial - LCD RX (after swap), debug TX
Serial1.begin(115200); // Serial1 - LCD TX, no RX
Serial.swap();
debugPrintln(String(F("SYSTEM: Starting HASwitchPlate v")) + String(haspVersion));
debugPrintln(String(F("SYSTEM: Last reset reason: ")) + String(ESP.getResetInfo()));
configRead(); // Check filesystem for a saved config.json
// Wait up to 5 seconds for serial input from LCD
while (!lcdConnected && (millis() < 5000))
{
nextionHandleInput();
}
if (lcdConnected)
{
debugPrintln(F("HMI: LCD responding, continuing program load"));
nextionSendCmd("connect");
}
else
{
debugPrintln(F("HMI: LCD not responding, continuing program load"));
}
espWifiSetup(); // Start up networking
if (mdnsEnabled)
{
MDNS.begin(haspNode); // Add mDNS hostname
// MDNS.addService("http", "tcp", 80); this breaks Wemo devices when Home Assistant discovery is enabled. No idea why.
if (debugTelnetEnabled)
{
MDNS.addService("telnet", "tcp", 23);
}
MDNS.addServiceTxt("arduino", "tcp", "app_name", "HASwitchPlate");
MDNS.addServiceTxt("arduino", "tcp", "app_version", String(haspVersion));
MDNS.addServiceTxt("arduino", "tcp", "mac", WiFi.macAddress());
MDNS.update();
}
if ((configPassword[0] != '\0') && (configUser[0] != '\0'))
{
httpOTAUpdate.setup(&webServer, "/update", configUser, configPassword);
}
else
{
httpOTAUpdate.setup(&webServer, "/update");
}
webServer.on("/", webHandleRoot);
webServer.on("/saveConfig", webHandleSaveConfig);
webServer.on("/resetConfig", webHandleResetConfig);
webServer.on("/firmware", webHandleFirmware);
webServer.on("/espfirmware", webHandleEspFirmware);
webServer.on("/lcdupload", HTTP_POST, []() { webServer.send(200, "text/plain", ""); }, webHandleLcdUpload);
webServer.on("/tftFileSize", webHandleTftFileSize);
webServer.on("/lcddownload", webHandleLcdDownload);
webServer.on("/reboot", webHandleReboot);
webServer.onNotFound(webHandleNotFound);
webServer.begin();
debugPrintln(String(F("HTTP: Server started @ http://")) + WiFi.localIP().toString());
espSetupOta();
// Create server and assign callbacks for MQTT
mqttClient.begin(mqttServer, atoi(mqttPort), wifiClient);
mqttClient.onMessage(mqttCallback);
mqttConnect();
// Setup motion sensor if configured
motionSetup();
if (debugTelnetEnabled)
{ // Setup telnet server for remote debug output
telnetServer.setNoDelay(true);
telnetServer.begin();
debugPrintln(String(F("TELNET: debug server enabled at telnet:")) + WiFi.localIP().toString());
}
debugPrintln(F("SYSTEM: System init complete."));
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void loop()
{ // Main execution loop
if (nextionHandleInput())
{ // Process user input from HMI
nextionProcessInput();
}
while ((WiFi.status() != WL_CONNECTED) || (WiFi.localIP().toString() == "0.0.0.0"))
{ // Check WiFi is connected and that we have a valid IP, retry until we do.
if (WiFi.status() == WL_CONNECTED)
{ // If we're currently connected, disconnect so we can try again
WiFi.disconnect();
}
espWifiReconnect();
}
if (!mqttClient.connected())
{ // Check MQTT connection
debugPrintln("MQTT: not connected, connecting.");
mqttConnect();
}
mqttClient.loop(); // MQTT client loop
ArduinoOTA.handle(); // Arduino OTA loop
webServer.handleClient(); // webServer loop
if (mdnsEnabled)
{
MDNS.update();
}
if ((lcdVersion < 1) && (millis() <= (nextionRetryMax * nextionCheckInterval)))
{ // Attempt to connect to LCD panel to collect model and version info during startup
nextionConnect();
}
else if ((lcdVersion > 0) && (millis() <= (nextionRetryMax * nextionCheckInterval)) && !startupCompleteFlag)
{ // We have LCD info, so trigger an update check + report
if (updateCheck())
{ // Send a status update if the update check worked
mqttStatusUpdate();
startupCompleteFlag = true;
}
}
else if ((millis() > (nextionRetryMax * nextionCheckInterval)) && !startupCompleteFlag)
{ // We still don't have LCD info so go ahead and run the rest of the checks once at startup anyway
updateCheck();
mqttStatusUpdate();
startupCompleteFlag = true;
}
if ((millis() - statusUpdateTimer) >= statusUpdateInterval)
{ // Run periodic status update
statusUpdateTimer = millis();
mqttStatusUpdate();
}
if ((millis() - updateCheckTimer) >= updateCheckInterval)
{ // Run periodic update check
updateCheckTimer = millis();
if (updateCheck())
{ // Send a status update if the update check worked
mqttStatusUpdate();
}
}
if (motionEnabled)
{ // Check on our motion sensor
motionUpdate();
}
if (debugTelnetEnabled)
{
handleTelnetClient(); // telnetClient loop
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Functions
////////////////////////////////////////////////////////////////////////////////////////////////////
void mqttConnect()
{ // MQTT connection and subscriptions
static bool mqttFirstConnect = true; // For the first connection, we want to send an OFF/ON state to
// trigger any automations, but skip that if we reconnect while
// still running the sketch
// Check to see if we have a broker configured and notify the user if not
if (mqttServer[0] == 0)
{
nextionSendCmd("page 0");
nextionSetAttr("p[0].b[1].txt", "\"WiFi Connected!\\rConfigure MQTT:\\r" + WiFi.localIP().toString() + "\\r\\r\\r\\r\\r\\r\\r \"");
nextionSetAttr("p[0].b[3].txt", "\"http://" + WiFi.localIP().toString() + "\"");
nextionSendCmd("vis 3,1");
while (mqttServer[0] == 0)
{ // Handle HTTP and OTA while we're waiting for MQTT to be configured
yield();
if (nextionHandleInput())
{ // Process user input from HMI
nextionProcessInput();
}
webServer.handleClient();
ArduinoOTA.handle();
}
}
// MQTT topic string definitions
mqttStateTopic = "hasp/" + String(haspNode) + "/state";
mqttCommandTopic = "hasp/" + String(haspNode) + "/command";
mqttGroupCommandTopic = "hasp/" + String(groupName) + "/command";
mqttStatusTopic = "hasp/" + String(haspNode) + "/status";
mqttSensorTopic = "hasp/" + String(haspNode) + "/sensor";
mqttLightCommandTopic = "hasp/" + String(haspNode) + "/light/switch";
mqttLightStateTopic = "hasp/" + String(haspNode) + "/light/state";
mqttLightBrightCommandTopic = "hasp/" + String(haspNode) + "/brightness/set";
mqttLightBrightStateTopic = "hasp/" + String(haspNode) + "/brightness/state";
mqttMotionStateTopic = "hasp/" + String(haspNode) + "/motion/state";
const String mqttCommandSubscription = mqttCommandTopic + "/#";
const String mqttGroupCommandSubscription = mqttGroupCommandTopic + "/#";
const String mqttLightSubscription = "hasp/" + String(haspNode) + "/light/#";
const String mqttLightBrightSubscription = "hasp/" + String(haspNode) + "/brightness/#";
// Loop until we're reconnected to MQTT
while (!mqttClient.connected())
{
// Create a reconnect counter
static uint8_t mqttReconnectCount = 0;
// Generate an MQTT client ID as haspNode + our MAC address
mqttClientId = String(haspNode) + "-" + String(espMac[0], HEX) + String(espMac[1], HEX) + String(espMac[2], HEX) + String(espMac[3], HEX) + String(espMac[4], HEX) + String(espMac[5], HEX);
nextionSendCmd("page 0");
nextionSetAttr("p[0].b[1].txt", "\"WiFi Connected:\\r" + WiFi.localIP().toString() + "\\rMQTT Connecting" + String(mqttServer) + "\"");
debugPrintln(String(F("MQTT: Attempting connection to broker ")) + String(mqttServer) + " as clientID " + mqttClientId);
// Set keepAlive, cleanSession, timeout
mqttClient.setOptions(30, true, 5000);
// declare LWT
mqttClient.setWill(mqttStatusTopic.c_str(), "OFF");
if (mqttClient.connect(mqttClientId.c_str(), mqttUser, mqttPassword))
{ // Attempt to connect to broker, setting last will and testament
// Subscribe to our incoming topics
if (mqttClient.subscribe(mqttCommandSubscription))
{
debugPrintln(String(F("MQTT: subscribed to ")) + mqttCommandSubscription);
}
if (mqttClient.subscribe(mqttGroupCommandSubscription))
{
debugPrintln(String(F("MQTT: subscribed to ")) + mqttGroupCommandSubscription);
}
if (mqttClient.subscribe(mqttLightSubscription))
{
debugPrintln(String(F("MQTT: subscribed to ")) + mqttLightSubscription);
}
if (mqttClient.subscribe(mqttLightBrightSubscription))
{
debugPrintln(String(F("MQTT: subscribed to ")) + mqttLightSubscription);
}
if (mqttClient.subscribe(mqttStatusTopic))
{
debugPrintln(String(F("MQTT: subscribed to ")) + mqttStatusTopic);
}
if (mqttFirstConnect)
{ // Force any subscribed clients to toggle OFF/ON when we first connect to
// make sure we get a full panel refresh at power on. Sending OFF,
// "ON" will be sent by the mqttStatusTopic subscription action.
debugPrintln(String(F("MQTT: binary_sensor state: [")) + mqttStatusTopic + "] : [OFF]");
mqttClient.publish(mqttStatusTopic, "OFF", true, 1);
mqttFirstConnect = false;
}
else
{
debugPrintln(String(F("MQTT: binary_sensor state: [")) + mqttStatusTopic + "] : [ON]");
mqttClient.publish(mqttStatusTopic, "ON", true, 1);
}
mqttReconnectCount = 0;
// Update panel with MQTT status
nextionSetAttr("p[0].b[1].txt", "\"WiFi Connected:\\r" + WiFi.localIP().toString() + "\\rMQTT Connected:\\r" + String(mqttServer) + "\"");
debugPrintln(F("MQTT: connected"));
if (nextionActivePage)
{
nextionSendCmd("page " + String(nextionActivePage));
}
}
else
{ // Retry until we give up and restart after connectTimeout seconds
mqttReconnectCount++;
if (mqttReconnectCount > ((connectTimeout / 10) - 1))
{
debugPrintln(String(F("MQTT connection attempt ")) + String(mqttReconnectCount) + String(F(" failed with rc ")) + String(mqttClient.returnCode()) + String(F(". Restarting device.")));
espReset();
}
debugPrintln(String(F("MQTT connection attempt ")) + String(mqttReconnectCount) + String(F(" failed with rc ")) + String(mqttClient.returnCode()) + String(F(". Trying again in 10 seconds.")));
nextionSetAttr("p[0].b[1].txt", "\"WiFi Connected:\\r" + WiFi.localIP().toString() + "\\rMQTT Connect to" + String(mqttServer) + "\\rFAILED rc=" + String(mqttClient.returnCode()) + "\\rRetry in 10 sec\"");
unsigned long mqttReconnectTimer = millis(); // record current time for our timeout
while ((millis() - mqttReconnectTimer) < 10000)
{ // Handle HTTP and OTA while we're waiting 10sec for MQTT to reconnect
yield();
if (nextionHandleInput())
{ // Process user input from HMI
nextionProcessInput();
}
webServer.handleClient();
ArduinoOTA.handle();
}
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void mqttCallback(String &strTopic, String &strPayload)
{ // Handle incoming commands from MQTT
// strTopic: homeassistant/haswitchplate/devicename/command/p[1].b[4].txt
// strPayload: "Lights On"
// subTopic: p[1].b[4].txt
// Incoming Namespace (replace /device/ with /group/ for group commands)
// '[...]/device/command' -m '' = No command requested, respond with mqttStatusUpdate()
// '[...]/device/command' -m 'dim=50' = nextionSendCmd("dim=50")
// '[...]/device/command/json' -m '["dim=5", "page 1"]' = nextionSendCmd("dim=50"), nextionSendCmd("page 1")
// '[...]/device/command/page' -m '1' = nextionSendCmd("page 1")
// '[...]/device/command/statusupdate' -m '' = mqttStatusUpdate()
// '[...]/device/command/lcdupdate' -m 'http://192.168.0.10/local/HASwitchPlate.tft' = nextionStartOtaDownload("http://192.168.0.10/local/HASwitchPlate.tft")
// '[...]/device/command/lcdupdate' -m '' = nextionStartOtaDownload("lcdFirmwareUrl")
// '[...]/device/command/espupdate' -m 'http://192.168.0.10/local/HASwitchPlate.ino.d1_mini.bin' = espStartOta("http://192.168.0.10/local/HASwitchPlate.ino.d1_mini.bin")
// '[...]/device/command/espupdate' -m '' = espStartOta("espFirmwareUrl")
// '[...]/device/command/p[1].b[4].txt' -m '' = nextionGetAttr("p[1].b[4].txt")
// '[...]/device/command/p[1].b[4].txt' -m '"Lights On"' = nextionSetAttr("p[1].b[4].txt", "\"Lights On\"")
debugPrintln(String(F("MQTT IN: '")) + strTopic + "' : '" + strPayload + "'");
if (((strTopic == mqttCommandTopic) || (strTopic == mqttGroupCommandTopic)) && (strPayload == ""))
{ // '[...]/device/command' -m '' = No command requested, respond with mqttStatusUpdate()
mqttStatusUpdate(); // return status JSON via MQTT
}
else if (strTopic == mqttCommandTopic || strTopic == mqttGroupCommandTopic)
{ // '[...]/device/command' -m 'dim=50' == nextionSendCmd("dim=50")
nextionSendCmd(strPayload);
}
else if (strTopic == (mqttCommandTopic + "/page") || strTopic == (mqttGroupCommandTopic + "/page"))
{ // '[...]/device/command/page' -m '1' == nextionSendCmd("page 1")
if (nextionActivePage != strPayload.toInt())
{ // Hass likes to send duplicate responses to things like page requests and there are no plans to fix that behavior, so try and track it locally
nextionActivePage = strPayload.toInt();
nextionSendCmd("page " + strPayload);
}
}
else if (strTopic == (mqttCommandTopic + "/json") || strTopic == (mqttGroupCommandTopic + "/json"))
{ // '[...]/device/command/json' -m '["dim=5", "page 1"]' = nextionSendCmd("dim=50"), nextionSendCmd("page 1")
nextionParseJson(strPayload); // Send to nextionParseJson()
}
else if (strTopic == (mqttCommandTopic + "/statusupdate") || strTopic == (mqttGroupCommandTopic + "/statusupdate"))
{ // '[...]/device/command/statusupdate' == mqttStatusUpdate()
mqttStatusUpdate(); // return status JSON via MQTT
}
else if (strTopic == (mqttCommandTopic + "/lcdupdate") || strTopic == (mqttGroupCommandTopic + "/lcdupdate"))
{ // '[...]/device/command/lcdupdate' -m 'http://192.168.0.10/local/HASwitchPlate.tft' == nextionStartOtaDownload("http://192.168.0.10/local/HASwitchPlate.tft")
if (strPayload == "")
{
nextionStartOtaDownload(lcdFirmwareUrl);
}
else
{
nextionStartOtaDownload(strPayload);
}
}
else if (strTopic == (mqttCommandTopic + "/espupdate") || strTopic == (mqttGroupCommandTopic + "/espupdate"))
{ // '[...]/device/command/espupdate' -m 'http://192.168.0.10/local/HASwitchPlate.ino.d1_mini.bin' == espStartOta("http://192.168.0.10/local/HASwitchPlate.ino.d1_mini.bin")
if (strPayload == "")
{
espStartOta(espFirmwareUrl);
}
else
{
espStartOta(strPayload);
}
}
else if (strTopic == (mqttCommandTopic + "/reboot") || strTopic == (mqttGroupCommandTopic + "/reboot"))
{ // '[...]/device/command/reboot' == reboot microcontroller)
debugPrintln(F("MQTT: Rebooting device"));
espReset();
}
else if (strTopic == (mqttCommandTopic + "/lcdreboot") || strTopic == (mqttGroupCommandTopic + "/lcdreboot"))
{ // '[...]/device/command/lcdreboot' == reboot LCD panel)
debugPrintln(F("MQTT: Rebooting LCD"));
nextionReset();
}
else if (strTopic == (mqttCommandTopic + "/factoryreset") || strTopic == (mqttGroupCommandTopic + "/factoryreset"))
{ // '[...]/device/command/factoryreset' == clear all saved settings)
configClearSaved();
}
else if (strTopic.startsWith(mqttCommandTopic) && (strPayload == ""))
{ // '[...]/device/command/p[1].b[4].txt' -m '' == nextionGetAttr("p[1].b[4].txt")
String subTopic = strTopic.substring(mqttCommandTopic.length() + 1);
mqttGetSubtopic = "/" + subTopic;
nextionGetAttr(subTopic);
}
else if (strTopic.startsWith(mqttGroupCommandTopic) && (strPayload == ""))
{ // '[...]/group/command/p[1].b[4].txt' -m '' == nextionGetAttr("p[1].b[4].txt")
String subTopic = strTopic.substring(mqttGroupCommandTopic.length() + 1);
mqttGetSubtopic = "/" + subTopic;
nextionGetAttr(subTopic);
}
else if (strTopic.startsWith(mqttCommandTopic))
{ // '[...]/device/command/p[1].b[4].txt' -m '"Lights On"' == nextionSetAttr("p[1].b[4].txt", "\"Lights On\"")
String subTopic = strTopic.substring(mqttCommandTopic.length() + 1);
nextionSetAttr(subTopic, strPayload);
}
else if (strTopic.startsWith(mqttGroupCommandTopic))
{ // '[...]/group/command/p[1].b[4].txt' -m '"Lights On"' == nextionSetAttr("p[1].b[4].txt", "\"Lights On\"")
String subTopic = strTopic.substring(mqttGroupCommandTopic.length() + 1);
nextionSetAttr(subTopic, strPayload);
}
else if (strTopic == mqttLightBrightCommandTopic)
{ // change the brightness from the light topic
int panelDim = map(strPayload.toInt(), 0, 255, 0, 100);
nextionSetAttr("dim", String(panelDim));
nextionSendCmd("dims=dim");
mqttClient.publish(mqttLightBrightStateTopic, strPayload);
}
else if (strTopic == mqttLightCommandTopic && strPayload == "OFF")
{ // set the panel dim OFF from the light topic, saving current dim level first
nextionSendCmd("dims=dim");
nextionSetAttr("dim", "0");
mqttClient.publish(mqttLightStateTopic, "OFF");
}
else if (strTopic == mqttLightCommandTopic && strPayload == "ON")
{ // set the panel dim ON from the light topic, restoring saved dim level
nextionSendCmd("dim=dims");
mqttClient.publish(mqttLightStateTopic, "ON");
}
else if (strTopic == mqttStatusTopic && strPayload == "OFF")
{ // catch a dangling LWT from a previous connection if it appears
mqttClient.publish(mqttStatusTopic, "ON");
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void mqttStatusUpdate()
{ // Periodically publish a JSON string indicating system status
String mqttStatusPayload = "{";
mqttStatusPayload += String(F("\"status\":\"available\","));
mqttStatusPayload += String(F("\"espVersion\":")) + String(haspVersion) + String(F(","));
if (updateEspAvailable)
{
mqttStatusPayload += String(F("\"updateEspAvailable\":true,"));
}
else
{
mqttStatusPayload += String(F("\"updateEspAvailable\":false,"));
}
if (lcdConnected)
{
mqttStatusPayload += String(F("\"lcdConnected\":true,"));
}
else
{
mqttStatusPayload += String(F("\"lcdConnected\":false,"));
}
mqttStatusPayload += String(F("\"lcdVersion\":\"")) + String(lcdVersion) + String(F("\","));
if (updateLcdAvailable)
{
mqttStatusPayload += String(F("\"updateLcdAvailable\":true,"));
}
else
{
mqttStatusPayload += String(F("\"updateLcdAvailable\":false,"));
}
mqttStatusPayload += String(F("\"espUptime\":")) + String(long(millis() / 1000)) + String(F(","));
mqttStatusPayload += String(F("\"signalStrength\":")) + String(WiFi.RSSI()) + String(F(","));
mqttStatusPayload += String(F("\"haspIP\":\"")) + WiFi.localIP().toString() + String(F("\","));
mqttStatusPayload += String(F("\"heapFree\":")) + String(ESP.getFreeHeap());
mqttStatusPayload += "}";
mqttClient.publish(mqttSensorTopic, mqttStatusPayload);
mqttClient.publish(mqttStatusTopic, "ON", true, 1);
debugPrintln(String(F("MQTT: status update: ")) + String(mqttStatusPayload));
debugPrintln(String(F("MQTT: binary_sensor state: [")) + mqttStatusTopic + "] : [ON]");
}
////////////////////////////////////////////////////////////////////////////////////////////////////
bool nextionHandleInput()
{ // Handle incoming serial data from the Nextion panel
// This will collect serial data from the panel and place it into the global buffer
// nextionReturnBuffer[nextionReturnIndex]
// Return: true if we've received a string of 3 consecutive 0xFF values
// Return: false otherwise
bool nextionCommandComplete = false;
static int nextionTermByteCnt = 0; // counter for our 3 consecutive 0xFFs
static String hmiDebug = "HMI IN: "; // assemble a string for debug output
if (Serial.available())
{
lcdConnected = true;
byte nextionCommandByte = Serial.read();
hmiDebug += (" 0x" + String(nextionCommandByte, HEX));
// check to see if we have one of 3 consecutive 0xFF which indicates the end of a command
if (nextionCommandByte == 0xFF)
{
nextionTermByteCnt++;
if (nextionTermByteCnt >= 3)
{ // We have received a complete command
nextionCommandComplete = true;
nextionTermByteCnt = 0; // reset counter
}
}
else
{
nextionTermByteCnt = 0; // reset counter if a non-term byte was encountered
}
nextionReturnBuffer[nextionReturnIndex] = nextionCommandByte;
nextionReturnIndex++;
}
if (nextionCommandComplete)
{
debugPrintln(hmiDebug);
hmiDebug = "HMI IN: ";
}
return nextionCommandComplete;
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionProcessInput()
{ // Process incoming serial commands from the Nextion panel
// Command reference: https://www.itead.cc/wiki/Nextion_Instruction_Set#Format_of_Device_Return_Data
// tl;dr, command byte, command data, 0xFF 0xFF 0xFF
if (nextionReturnBuffer[0] == 0x65)
{ // Handle incoming touch command
// 0x65+Page ID+Component ID+TouchEvent+End
// Return this data when the touch event created by the user is pressed.
// Definition of TouchEvent: Press Event 0x01, Release Event 0X00
// Example: 0x65 0x00 0x02 0x01 0xFF 0xFF 0xFF
// Meaning: Touch Event, Page 0, Object 2, Press
String nextionPage = String(nextionReturnBuffer[1]);
String nextionButtonID = String(nextionReturnBuffer[2]);
byte nextionButtonAction = nextionReturnBuffer[3];
if (nextionButtonAction == 0x01)
{
debugPrintln(String(F("HMI IN: [Button ON] 'p[")) + nextionPage + "].b[" + nextionButtonID + "]'");
String mqttButtonTopic = mqttStateTopic + "/p[" + nextionPage + "].b[" + nextionButtonID + "]";
debugPrintln(String(F("MQTT OUT: '")) + mqttButtonTopic + "' : 'ON'");
mqttClient.publish(mqttButtonTopic, "ON");
}
if (nextionButtonAction == 0x00)
{
debugPrintln(String(F("HMI IN: [Button OFF] 'p[")) + nextionPage + "].b[" + nextionButtonID + "]'");
String mqttButtonTopic = mqttStateTopic + "/p[" + nextionPage + "].b[" + nextionButtonID + "]";
debugPrintln(String(F("MQTT OUT: '")) + mqttButtonTopic + "' : 'OFF'");
mqttClient.publish(mqttButtonTopic, "OFF");
// Now see if this object has a .val that might have been updated.
// works for sliders, two-state buttons, etc, throws a 0x1A error for normal buttons
// which we'll catch and ignore
mqttGetSubtopic = "/p[" + nextionPage + "].b[" + nextionButtonID + "].val";
nextionGetAttr("p[" + nextionPage + "].b[" + nextionButtonID + "].val");
}
}
else if (nextionReturnBuffer[0] == 0x66)
{ // Handle incoming "sendme" page number
// 0x66+PageNum+End
// Example: 0x66 0x02 0xFF 0xFF 0xFF
// Meaning: page 2
String nextionPage = String(nextionReturnBuffer[1]);
debugPrintln(String(F("HMI IN: [sendme Page] '")) + nextionPage + "'");
if ((nextionActivePage != nextionPage.toInt()) && ((nextionPage != "0") || nextionReportPage0))
{ // If we have a new page AND ( (it's not "0") OR (we've set the flag to report 0 anyway) )
nextionActivePage = nextionPage.toInt();
String mqttPageTopic = mqttStateTopic + "/page";
debugPrintln(String(F("MQTT OUT: '")) + mqttPageTopic + "' : '" + nextionPage + "'");
mqttClient.publish(mqttPageTopic, nextionPage);
}
}
else if (nextionReturnBuffer[0] == 0x67)
{ // Handle touch coordinate data
// 0X67+Coordinate X High+Coordinate X Low+Coordinate Y High+Coordinate Y Low+TouchEvent+End
// Example: 0X67 0X00 0X7A 0X00 0X1E 0X01 0XFF 0XFF 0XFF
// Meaning: Coordinate (122,30), Touch Event: Press
// issue Nextion command "sendxy=1" to enable this output
uint16_t xCoord = nextionReturnBuffer[1];
xCoord = xCoord * 256 + nextionReturnBuffer[2];
uint16_t yCoord = nextionReturnBuffer[3];
yCoord = yCoord * 256 + nextionReturnBuffer[4];
String xyCoord = String(xCoord) + ',' + String(yCoord);
byte nextionTouchAction = nextionReturnBuffer[5];
if (nextionTouchAction == 0x01)
{
debugPrintln(String(F("HMI IN: [Touch ON] '")) + xyCoord + "'");
String mqttTouchTopic = mqttStateTopic + "/touchOn";
debugPrintln(String(F("MQTT OUT: '")) + mqttTouchTopic + "' : '" + xyCoord + "'");
mqttClient.publish(mqttTouchTopic, xyCoord);
}
else if (nextionTouchAction == 0x00)
{
debugPrintln(String(F("HMI IN: [Touch OFF] '")) + xyCoord + "'");
String mqttTouchTopic = mqttStateTopic + "/touchOff";
debugPrintln(String(F("MQTT OUT: '")) + mqttTouchTopic + "' : '" + xyCoord + "'");
mqttClient.publish(mqttTouchTopic, xyCoord);
}
}
else if (nextionReturnBuffer[0] == 0x70)
{ // Handle get string return
// 0x70+ASCII string+End
// Example: 0x70 0x41 0x42 0x43 0x44 0x31 0x32 0x33 0x34 0xFF 0xFF 0xFF
// Meaning: String data, ABCD1234
String getString;
for (int i = 1; i < nextionReturnIndex - 3; i++)
{ // convert the payload into a string
getString += (char)nextionReturnBuffer[i];
}
debugPrintln(String(F("HMI IN: [String Return] '")) + getString + "'");
if (mqttGetSubtopic == "")
{ // If there's no outstanding request for a value, publish to mqttStateTopic
debugPrintln(String(F("MQTT OUT: '")) + mqttStateTopic + "' : '" + getString + "]");
mqttClient.publish(mqttStateTopic, getString);
}
else
{ // Otherwise, publish the to saved mqttGetSubtopic and then reset mqttGetSubtopic
String mqttReturnTopic = mqttStateTopic + mqttGetSubtopic;
debugPrintln(String(F("MQTT OUT: '")) + mqttReturnTopic + "' : '" + getString + "]");
mqttClient.publish(mqttReturnTopic, getString);
mqttGetSubtopic = "";
}
}
else if (nextionReturnBuffer[0] == 0x71)
{ // Handle get int return
// 0x71+byte1+byte2+byte3+byte4+End (4 byte little endian)
// Example: 0x71 0x7B 0x00 0x00 0x00 0xFF 0xFF 0xFF
// Meaning: Integer data, 123
unsigned long getInt = nextionReturnBuffer[4];
getInt = getInt * 256 + nextionReturnBuffer[3];
getInt = getInt * 256 + nextionReturnBuffer[2];
getInt = getInt * 256 + nextionReturnBuffer[1];
String getString = String(getInt);
debugPrintln(String(F("HMI IN: [Int Return] '")) + getString + "'");
if (lcdVersionQueryFlag)
{
lcdVersion = getInt;
lcdVersionQueryFlag = false;
debugPrintln(String(F("HMI IN: lcdVersion '")) + String(lcdVersion) + "'");
}
else if (mqttGetSubtopic == "")
{
mqttClient.publish(mqttStateTopic, getString);
}
// Otherwise, publish the to saved mqttGetSubtopic and then reset mqttGetSubtopic
else
{
String mqttReturnTopic = mqttStateTopic + mqttGetSubtopic;
mqttClient.publish(mqttReturnTopic, getString);
mqttGetSubtopic = "";
}
}
else if (nextionReturnBuffer[0] == 0x63 && nextionReturnBuffer[1] == 0x6f && nextionReturnBuffer[2] == 0x6d && nextionReturnBuffer[3] == 0x6f && nextionReturnBuffer[4] == 0x6b)
{ // Catch 'comok' response to 'connect' command: https://www.itead.cc/blog/nextion-hmi-upload-protocol
String comokField;
uint8_t comokFieldCount = 0;
byte comokFieldSeperator = 0x2c; // ","
for (uint8_t i = 0; i <= nextionReturnIndex; i++)
{ // cycle through each byte looking for our field seperator
if (nextionReturnBuffer[i] == comokFieldSeperator)
{ // Found the end of a field, so do something with it. Maybe.
if (comokFieldCount == 2)
{
nextionModel = comokField;
debugPrintln(String(F("HMI IN: nextionModel: ")) + nextionModel);
}
comokFieldCount++;
comokField = "";
}
else
{
comokField += String(char(nextionReturnBuffer[i]));
}
}
}
else if (nextionReturnBuffer[0] == 0x1A)
{ // Catch 0x1A error, possibly from .val query against things that might not support that request
// 0x1A+End
// ERROR: Variable name invalid
// We'll be triggering this a lot due to requesting .val on every component that sends us a Touch Off
// Just reset mqttGetSubtopic and move on with life.
mqttGetSubtopic = "";
}
nextionReturnIndex = 0; // Done handling the buffer, reset index back to 0
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionSetAttr(String hmiAttribute, String hmiValue)
{ // Set the value of a Nextion component attribute
Serial1.print(hmiAttribute);
Serial1.print("=");
Serial1.print(utf8ascii(hmiValue));
Serial1.write(nextionSuffix, sizeof(nextionSuffix));
debugPrintln(String(F("HMI OUT: '")) + hmiAttribute + "=" + hmiValue + "'");
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionGetAttr(String hmiAttribute)
{ // Get the value of a Nextion component attribute
// This will only send the command to the panel requesting the attribute, the actual
// return of that value will be handled by nextionProcessInput and placed into mqttGetSubtopic
Serial1.print("get " + hmiAttribute);
Serial1.write(nextionSuffix, sizeof(nextionSuffix));
debugPrintln(String(F("HMI OUT: 'get ")) + hmiAttribute + "'");
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionSendCmd(String nextionCmd)
{ // Send a raw command to the Nextion panel
Serial1.print(utf8ascii(nextionCmd));
Serial1.write(nextionSuffix, sizeof(nextionSuffix));
debugPrintln(String(F("HMI OUT: ")) + nextionCmd);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionParseJson(String &strPayload)
{ // Parse an incoming JSON array into individual Nextion commands
DynamicJsonBuffer nextionJsonBuffer(256);
JsonArray &nextionCommands = nextionJsonBuffer.parseArray(strPayload, 1);
for (uint8_t i = 0; i < nextionCommands.size(); i++)
{
nextionSendCmd(nextionCommands[i]);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
void nextionStartOtaDownload(String otaUrl)
{ // Upload firmware to the Nextion LCD via HTTP download
// based in large part on code posted by indev2 here:
// http://support.iteadstudio.com/support/discussions/topics/11000007686/page/2
int lcdOtaFileSize = 0;
String lcdOtaNextionCmd;
int lcdOtaChunkCounter = 0;
uint16_t lcdOtaPartNum = 0;
int lcdOtaTransferred = 0;
int lcdOtaPercentComplete = 0;
debugPrintln(String(F("LCD OTA: Attempting firmware download from:")) + otaUrl);
HTTPClient lcdOtaHttp;
lcdOtaHttp.begin(otaUrl);
int lcdOtaHttpReturn = lcdOtaHttp.GET();
if (lcdOtaHttpReturn > 0)
{ // HTTP header has been sent and Server response header has been handled
debugPrintln(String(F("LCD OTA: HTTP GET return code:")) + String(lcdOtaHttpReturn));
if (lcdOtaHttpReturn == HTTP_CODE_OK)
{ // file found at server
// get length of document (is -1 when Server sends no Content-Length header)
int lcdOtaRemaining = lcdOtaHttp.getSize();
lcdOtaFileSize = lcdOtaRemaining;
int lcdOtaParts = (lcdOtaRemaining / 4096) + 1;
uint8_t lcdOtaBuffer[128] = {}; // max size of ESP8266 UART buffer
debugPrintln(String(F("LCD OTA: File found at Server. Size ")) + String(lcdOtaRemaining) + String(F(" bytes in ")) + String(lcdOtaParts) + String(F(" 4k chunks.")));
WiFiUDP::stopAll(); // Keep mDNS responder and MQTT traffic from breaking things
if (mqttClient.connected())
{
debugPrintln(F("LCD OTA: LCD firmware upload starting, closing MQTT connection."));
mqttClient.publish(mqttStatusTopic, "OFF");
mqttClient.publish(mqttSensorTopic, "{\"status\": \"unavailable\"}");
mqttClient.disconnect();
}
// get tcp stream
WiFiClient *stream = lcdOtaHttp.getStreamPtr();
// Send empty command
Serial1.write(nextionSuffix, sizeof(nextionSuffix));
Serial1.flush();
nextionHandleInput();
String lcdOtaNextionCmd = "whmi-wri " + String(lcdOtaFileSize) + ",115200,0";
debugPrintln(String(F("LCD OTA: Sending LCD upload command: ")) + lcdOtaNextionCmd);
Serial1.print(lcdOtaNextionCmd);
Serial1.write(nextionSuffix, sizeof(nextionSuffix));
Serial1.flush();
if (nextionOtaResponse())
{
debugPrintln(F("LCD OTA: LCD upload command accepted."));
}
else
{
debugPrintln(F("LCD OTA: LCD upload command FAILED. Restarting device."));
espReset();
}
debugPrintln(F("LCD OTA: Starting update"));
while (lcdOtaHttp.connected() && (lcdOtaRemaining > 0 || lcdOtaRemaining == -1))
{ // Write incoming data to panel as it arrives
// get available data size
size_t lcdOtaHttpSize = stream->available();
if (lcdOtaHttpSize)
{
// read up to 128 bytes
int lcdOtaChunkSize = stream->readBytes(lcdOtaBuffer, ((lcdOtaHttpSize > sizeof(lcdOtaBuffer)) ? sizeof(lcdOtaBuffer) : lcdOtaHttpSize));
// write it to panel
Serial1.flush();
Serial1.write(lcdOtaBuffer, lcdOtaChunkSize);
lcdOtaChunkCounter += lcdOtaChunkSize;
if (lcdOtaChunkCounter >= 4096)
{
Serial1.flush();
lcdOtaPartNum++;
lcdOtaTransferred += lcdOtaChunkCounter;
lcdOtaPercentComplete = (lcdOtaTransferred * 100) / lcdOtaFileSize;
lcdOtaChunkCounter = 0;
if (nextionOtaResponse())
{
// debugPrintln(String(F("LCD OTA: Part ")) + String(lcdOtaPartNum) + String(F(" OK, ")) + String(lcdOtaPercentComplete) + String(F("% complete")));
}
else
{
debugPrintln(String(F("LCD OTA: Part ")) + String(lcdOtaPartNum) + String(F(" FAILED, ")) + String(lcdOtaPercentComplete) + String(F("% complete")));
debugPrintln(F("LCD OTA: failure"));
delay(2000); // extra delay while the LCD does its thing
espReset();
}
}
else
{
delay(20);
}
if (lcdOtaRemaining > 0)
{
lcdOtaRemaining -= lcdOtaChunkSize;
}
}
}
lcdOtaPartNum++;
lcdOtaTransferred += lcdOtaChunkCounter;
if ((lcdOtaTransferred == lcdOtaFileSize) && nextionOtaResponse())
{
debugPrintln(String(F("LCD OTA: success, wrote ")) + String(lcdOtaTransferred) + " of " + String(lcdOtaFileSize) + " bytes.");
delay(5000); // extra delay while the LCD does its thing
espReset();
}
else
{
debugPrintln(F("LCD OTA: failure"));
delay(2000); // extra delay while the LCD does its thing
espReset();
}
}
}
else
{
debugPrintln(String(F("LCD OTA: HTTP GET failed, error code ")) + lcdOtaHttp.errorToString(lcdOtaHttpReturn));
espReset();
}
lcdOtaHttp.end();
}
////////////////////////////////////////////////////////////////////////////////////////////////////
bool nextionOtaResponse()
{ // Monitor the serial port for a 0x05 response within our timeout
unsigned long nextionCommandTimeout = 2000; // timeout for receiving termination string in milliseconds
unsigned long nextionCommandTimer = millis(); // record current time for our timeout
bool otaSuccessVal = false;
while ((millis() - nextionCommandTimer) < nextionCommandTimeout)
{
if (Serial.available())
{
byte inByte = Serial.read();
if (inByte == 0x5)
{
otaSuccessVal = true;
break;
}
else
{
...
This file has been truncated, please download it to see its full contents.
Comments
Please log in or sign up to comment.