From f2fc630bea10bd57f9ec0852dd0b6350fb76e2d8 Mon Sep 17 00:00:00 2001 From: Piotr Gaczkowski Date: Thu, 6 Jun 2024 16:54:13 +0200 Subject: [PATCH] Add MQTT switch support including HomeAssistant autodiscovery --- .envrc | 12 +- flake.lock | 61 +++++++++ flake.nix | 23 ++++ platformio.ini | 16 ++- src/odessa.cpp | 337 ++++++++++++++++++++++++++++++++++++------------- src/settings.h | 16 +-- 6 files changed, 365 insertions(+), 100 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc index 4a4726a..a2555e9 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,11 @@ -use_nix +#!/bin/bash + +# This is a better (faster) alternative to the built-in Nix support +if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4=" +fi + +# We only want to use Nix platformio if we aren't already in a platformio IDE +if [ "$TERM_PROGRAM" != "vscode" ]; then + use flake +fi diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9e4010f --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1717111066, + "narHash": "sha256-3lK95ALr5/0GZhsRI7XZgTKFg5HmMIi7xn/8fHuQvKk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0f1a94c815595e1bb4143368d407b2ceb0618931", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e93ec1f --- /dev/null +++ b/flake.nix @@ -0,0 +1,23 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05-small"; + flake-utils.url = "github:numtide/flake-utils"; + }; + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + with pkgs; + { + devShells.default = mkShell { + buildInputs = [ + platformio + ]; + }; + } + ); +} diff --git a/platformio.ini b/platformio.ini index 5d49f31..e106eeb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,15 +1,23 @@ [env] -platform = espressif32@^5 -framework = arduino +platform = espressif32@^6.7.0 +platform_packages = framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32.git#2.0.17 monitor_speed = 115200 +framework = arduino lib_deps = - https://github.com/plapointe6/EspMQTTClient @ ^1.13.2 + knolleary/PubSubClient@^2.8 bblanchon/ArduinoJson @ ^6.19.4 https://github.com/DoomHammer/Adafruit-GFX-Library#enable-utf-8 https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-DMA @ ^3.0.9 + https://gitlab.com/doomhammerng/wifi-manager#rpi-picow + ropg/ezTime@^0.8.3 + ayushsharma82/ElegantOTA@^3.1.1 +build_flags = + -DELEGANTOTA_USE_ASYNC_WEBSERVER [env:esp32] board = esp32dev +board_build.filesystem = littlefs [env:mhetesp32minikit] -board = mhetesp32minikit \ No newline at end of file +board = mhetesp32minikit +board_build.filesystem = littlefs diff --git a/src/odessa.cpp b/src/odessa.cpp index 68e78c3..c6440a9 100644 --- a/src/odessa.cpp +++ b/src/odessa.cpp @@ -1,8 +1,16 @@ #include +#include + +#include +#include + +#include // https://arduinojson.org/ #include // https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-DMA -#include // https://arduinojson.org/ -#include // https://github.com/plapointe6/EspMQTTClient +#include + +#include +#include #include "hack-regular-4.h" @@ -10,9 +18,10 @@ #define MAX_PAYLOAD 1024 -#define PANEL_RES_X 128 // Number of pixels wide of each INDIVIDUAL panel module. -#define PANEL_RES_Y 64 // Number of pixels tall of each INDIVIDUAL panel module. -#define PANEL_CHAIN 1 // Total number of panels chained one to another +#define PANEL_RES_X \ + 128 // Number of pixels wide of each INDIVIDUAL panel module. +#define PANEL_RES_Y 64 // Number of pixels tall of each INDIVIDUAL panel module. +#define PANEL_CHAIN 1 // Total number of panels chained one to another #ifdef ARDUINO_MH_ET_LIVE_ESP32MINIKIT #define E_PIN 18 @@ -20,7 +29,6 @@ #define E_PIN 32 #endif -// MatrixPanel_I2S_DMA dma_display; MatrixPanel_I2S_DMA *dma_display = nullptr; uint16_t myBLACK = dma_display->color565(0, 0, 0); @@ -33,133 +41,288 @@ const bool hack_font = true; std::vector> transport_times; -EspMQTTClient client( - ssid, - password, - MQTT_BROKER, - MQTT_USER, - MQTT_PASSWORD, - "Odessa", - MQTT_BROKER_PORT -); +enum PowerSwitch { off = 0, on = 1, unknown = -1 }; + +const char* on_state = "ON"; +const char* off_state = "OFF"; + +PowerSwitch powerSwitch = PowerSwitch::unknown; + +WiFiClient wifiClient; +PubSubClient client(wifiClient); DynamicJsonDocument doc(MAX_PAYLOAD); -void drawText() -{ - dma_display->setTextSize(1); // size 1 == 8 pixels high - dma_display->setTextWrap(false); // Don't wrap at end of line - will do ourselves +AsyncWebServer server(80); + +unsigned long ota_progress_millis = 0; + +const char* homeassistant_discovery_topic_prefix = "homeassistant/switch"; + +void onOTAStart() { Serial.println("OTA update started!"); } - if (hack_font) - { - dma_display->setCursor(1, 9); +void onOTAProgress(size_t current, size_t final) { + if (millis() - ota_progress_millis > 1000) { + ota_progress_millis = millis(); + Serial.printf("OTA Progress Current: %u bytes, Final: %u bytes\n", current, + final); } - else - { - dma_display->setCursor(0, 1); +} + +void onOTAEnd(bool success) { + if (success) { + Serial.println("OTA update finished successfully!"); + } else { + Serial.println("There was an error during OTA update!"); } +} + +void drawText() { + if (powerSwitch == PowerSwitch::on) { + dma_display->setTextSize(1); // size 1 == 8 pixels high + dma_display->setTextWrap( + false); // Don't wrap at end of line - will do ourselves + + if (hack_font) { + dma_display->setCursor(1, 9); + } else { + dma_display->setCursor(0, 1); + } + + Timezone tz; + tz.setLocation("Europe/Warsaw"); + + dma_display->clearScreen(); - uint8_t w = 0; - char buffer[100]; + dma_display->setTextColor(myWHITE); - for (auto &element : transport_times) - { - dma_display->setTextColor(myGREEN); + dma_display->setCursor(0, 8); + dma_display->printf("%04d-%02d-%02d", tz.year(), tz.month(), tz.day()); - dma_display->setCursor(0, 9 * w + 8); - snprintf(buffer, 100, "%2d", atoi(std::get<0>(element).c_str())); - dma_display->println(buffer); + dma_display->setCursor(86, 8); + dma_display->printf("%02d:%02d:%02d", tz.hour(), tz.minute(), tz.second()); - dma_display->setTextColor(myBLUE); - dma_display->setCursor(12, 9 * w + 8); - snprintf(buffer, 100, "%-20s", std::get<1>(element).substr(0, 20).c_str()); - dma_display->println(buffer); + uint8_t w = 1; + char buffer[100]; - dma_display->setTextColor(myRED); - dma_display->setCursor(116, 9 * w + 8); - snprintf(buffer, 100, "%2d", atoi(std::get<2>(element).c_str())); - dma_display->println(buffer); - w++; + for (auto &element : transport_times) { + dma_display->setTextColor(myGREEN); + + dma_display->setCursor(0, 9 * w + 8); + snprintf(buffer, 100, "%2d", atoi(std::get<0>(element).c_str())); + dma_display->println(buffer); + + dma_display->setTextColor(myBLUE); + dma_display->setCursor(12, 9 * w + 8); + snprintf(buffer, 100, "%-20s", + std::get<1>(element).substr(0, 20).c_str()); + dma_display->println(buffer); + + dma_display->setTextColor(myRED); + dma_display->setCursor(116, 9 * w + 8); + snprintf(buffer, 100, "%2d", atoi(std::get<2>(element).c_str())); + dma_display->println(buffer); + w++; + } + dma_display->flipDMABuffer(); } } -void onConnectionEstablished() -{ - Serial.println("Connection established"); +void turnDisplayOff() { + Serial.println("Turn display off\n"); + powerSwitch = PowerSwitch::off; + client.publish(switchStateTopic, off_state); + dma_display->clearScreen(); + dma_display->flipDMABuffer(); +} - client.subscribe(topic, [](const String &payload) - { - Serial.println(payload); +void turnDisplayOn() { + Serial.println("Turn display on\n"); + powerSwitch = PowerSwitch::on; + client.publish(switchStateTopic, on_state); + drawText(); +} - deserializeJson(doc, payload); +void handleFeedUpdate(byte *payload, unsigned int length) { + Serial.println("Handling feed update"); + deserializeJson(doc, payload, length); + + JsonArray ztm = doc["ztm"]; + + transport_times.clear(); + + for (JsonObject transport : ztm) { + std::string number = transport["n"]; + std::string direction = transport["d"]; + std::string time = transport["t"]; + transport_times.emplace_back(make_tuple(number, direction, time)); + Serial.print("Number: "); + Serial.println(number.c_str()); + Serial.print("Direction: "); + Serial.println(direction.c_str()); + Serial.print("Time: "); + Serial.println(time.c_str()); + } +} + +void handleSwitchStateUpdate(byte *payload, unsigned int length) { + Serial.println("Handling switch update"); + if (strncmp(on_state, (const char *)payload, length) == 0) { + turnDisplayOn(); + } + if (strncmp(off_state, (const char *)payload, length) == 0) { + turnDisplayOff(); + } +} - JsonArray ztm = doc["ztm"]; +void sendHassDiscoveryMessage() { + char homeassistant_discovery_topic[256]; + char serialized_json[MAX_PAYLOAD]; - transport_times.clear(); + snprintf(homeassistant_discovery_topic, 256, "%s/%s/config", homeassistant_discovery_topic_prefix, client_id); + doc.clear(); - for (JsonObject transport : ztm) { - std::string number = transport["n"]; - std::string direction = transport["d"]; - std::string time = transport["t"]; - transport_times.emplace_back(make_tuple(number, direction, time)); - Serial.print("Number: "); - Serial.println(number.c_str()); - Serial.print("Direction: "); - Serial.println(direction.c_str()); - Serial.print("Time: "); - Serial.println(time.c_str()); - dma_display->clearScreen(); - } - }); + doc["name"] = "Tramwajomat"; + doc["device_class"] = "switch"; + doc["state_topic"] = switchStateTopic; + doc["command_topic"] = switchSetTopic; + doc["device"]["name"] = "Tramwajomat"; + doc["unique_id"] = client_id; + + serializeJson(doc, serialized_json); + + client.publish(homeassistant_discovery_topic, serialized_json); } -void setup() -{ - Serial.begin(115200); +void onConnectionEstablished() { + Serial.println("Connection established"); - client.setMaxPacketSize(MAX_PAYLOAD); + client.subscribe(feedTopic); + client.subscribe(switchSetTopic); + client.setCallback( + [](const char *messageTopic, byte *payload, unsigned int length) { + Serial.printf("Got a message in topic %s\n", messageTopic); + Serial.printf("Payload:\n%s\n", payload); + if (strcmp(messageTopic, feedTopic) == 0) { + handleFeedUpdate(payload, length); + } + if (strcmp(messageTopic, switchSetTopic) == 0) { + handleSwitchStateUpdate(payload, length); + } + }); + + sendHassDiscoveryMessage(); +} - transport_times.emplace_back(std::make_tuple("5", "żółty", "2")); - transport_times.emplace_back(std::make_tuple("12", "Hackerspace", "12")); +void setupSerial() { Serial.begin(115200); } + +void setupFilesystem() { LittleFS.begin(); } - HUB75_I2S_CFG mxconfig( - PANEL_RES_X, - PANEL_RES_Y, - PANEL_CHAIN); +void setupDisplay() { + HUB75_I2S_CFG mxconfig(PANEL_RES_X, PANEL_RES_Y, PANEL_CHAIN); mxconfig.gpio.e = E_PIN; + mxconfig.double_buff = true; - // Display Setup dma_display = new MatrixPanel_I2S_DMA(mxconfig); dma_display->begin(); dma_display->utf8(true); - dma_display->setBrightness8(90); // 0-255 + dma_display->setBrightness8(128); // 0-255 dma_display->clearScreen(); - if (hack_font) - { + if (hack_font) { dma_display->setFont(&Hack_Regular4pt8b); } - dma_display->fillRect(0, 0, dma_display->width(), dma_display->height(), dma_display->color444(0, 15, 0)); + dma_display->fillRect(0, 0, dma_display->width(), dma_display->height(), + dma_display->color444(0, 15, 0)); delay(500); - dma_display->drawRect(0, 0, dma_display->width(), dma_display->height(), dma_display->color444(15, 15, 0)); + dma_display->drawRect(0, 0, dma_display->width(), dma_display->height(), + dma_display->color444(15, 15, 0)); delay(500); - dma_display->drawLine(0, 0, dma_display->width() - 1, dma_display->height() - 1, dma_display->color444(15, 0, 0)); - dma_display->drawLine(dma_display->width() - 1, 0, 0, dma_display->height() - 1, dma_display->color444(15, 0, 0)); + dma_display->drawLine(0, 0, dma_display->width() - 1, + dma_display->height() - 1, + dma_display->color444(15, 0, 0)); + dma_display->drawLine(dma_display->width() - 1, 0, 0, + dma_display->height() - 1, + dma_display->color444(15, 0, 0)); delay(500); dma_display->fillScreen(dma_display->color444(0, 0, 0)); } -void loop() -{ +void setupWifi() { + bool connected = WifiManager.connectToWifi(); + + if (!connected) { + WifiManager.startManagementServer("Tramwajomat"); + } +} + +void setupMqtt() { + client.setServer(MQTT_BROKER, MQTT_BROKER_PORT); + client.setBufferSize(MAX_PAYLOAD); + + waitForSync(); +} + +void setupOta() { + server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "Hi! This is ElegantOTA AsyncDemo."); + }); + + ElegantOTA.begin(&server); + ElegantOTA.onStart(onOTAStart); + ElegantOTA.onProgress(onOTAProgress); + ElegantOTA.onEnd(onOTAEnd); + + server.begin(); +} + +void setup() { + setupSerial(); + setupFilesystem(); + setupDisplay(); + setupWifi(); + setupMqtt(); + setupOta(); + + // Setup placeholders + transport_times.emplace_back(std::make_tuple("5", "żółty", "2")); + transport_times.emplace_back(std::make_tuple("12", "Hackerspace", "12")); + + turnDisplayOn(); +} + +void reconnectMqtt() { + while (!client.connected()) { + Serial.println("Connecting to MQTT broker using supplied credentials"); + bool connected = client.connect(client_id, MQTT_USER, MQTT_PASSWORD); + + if (connected) { + onConnectionEstablished(); + } else { + Serial.printf("MQTT connection failed, rc=%d, retry in 5 seconds\n", + client.state()); + delay(5000); + } + } +} + +void loop() { + WifiManager.check(); + + ElegantOTA.loop(); + + if (!client.connected()) { + reconnectMqtt(); + } + client.loop(); - // animate by going through the colour wheel for the first two lines drawText(); - delay(20); + delay(50); } diff --git a/src/settings.h b/src/settings.h index 559555c..3073f80 100644 --- a/src/settings.h +++ b/src/settings.h @@ -1,10 +1,10 @@ -// Wifi & MQTT -const char *ssid = "Your SSID goes here"; -const char *password = "AVerySafePassw0rd"; - -const char* MQTT_BROKER = "X.Y.Z.W"; +// MQTT connection details +const char *MQTT_BROKER = "mqtt.broker.address.example.com"; const uint16_t MQTT_BROKER_PORT = 1883; -const char* MQTT_USER = "username"; -const char* MQTT_PASSWORD = "password"; +const char *MQTT_USER = "username"; +const char *MQTT_PASSWORD = "password"; -const String topic="feed/public_transport"; +const char *feedTopic = "feed/public_transport"; +const char *switchSetTopic = "area/room/odessa/switch/set"; +const char *switchStateTopic = "homeassistant/switch/odessa/state"; +const char *client_id = "odessa";