diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c68d02e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pio +.vscode +include/config.h diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd9ff65 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# TileController + +## Config + +Please add a ``config.h`` file with the following content: + +````c++ +#define WLAN_SSID "" +#define WLAN_PASS "" + +#define MQTT_BROKER "" +#define MQTT_PORT + +#define NANOLEAF_BASE_URL "" +#define NANOLEAF_AUTH_TOKEN "" + +#define FRIEND_ID "" +#define DEVICE_ID "" +```` diff --git a/include/ColorPaletteAdapter.h b/include/ColorPaletteAdapter.h new file mode 100644 index 0000000..7c423ad --- /dev/null +++ b/include/ColorPaletteAdapter.h @@ -0,0 +1,25 @@ +#ifndef COLORPALETTEADAPTER_H +#define COLORPALETTEADAPTER_H + +#include "TopicAdapter.h" +#include "NanoleafApiWrapper.h" + +class ColorPaletteAdapter final : public TopicAdapter { +public: + explicit ColorPaletteAdapter(NanoleafApiWrapper &nanoleaf): nanoleaf(nanoleaf), topic("color") { + } + + [[nodiscard]] const char *getTopic() const override { + return topic; + } + + void callback(char *topic, const JsonObject &payload, unsigned int length) override { + nanoleaf.setStaticColors(payload); + } + +private: + NanoleafApiWrapper &nanoleaf; + const char *topic; +}; + +#endif diff --git a/include/MQTTClient.h b/include/MQTTClient.h new file mode 100644 index 0000000..3fb41ac --- /dev/null +++ b/include/MQTTClient.h @@ -0,0 +1,37 @@ +#ifndef MQTTCLIENT_H +#define MQTTCLIENT_H + +#include +#include +#include +#include +#include "TopicAdapter.h" + +class MQTTClient { +public: + explicit MQTTClient(WiFiClient &wifiClient); + + void setup(const char *mqttBroker, int mqttPort, const char *friendId, const char *deviceId); + + void loop(); + + void publish(const char *topic, const JsonDocument &jsonPayload); + + void addTopicAdapter(TopicAdapter *adapter); + +private: + void reconnect(); + + char *buildTopic(const TopicAdapter *adapter) const; + + void callback(char *topic, const byte *payload, unsigned int length) const; + + static bool matches(const char *subscribedTopic, const char *receivedTopic); + + PubSubClient client; + const char *friendId{}; + const char *deviceId{}; + static std::vector topicAdapters; +}; + +#endif diff --git a/include/NanoleafApiWrapper.h b/include/NanoleafApiWrapper.h new file mode 100644 index 0000000..03e504a --- /dev/null +++ b/include/NanoleafApiWrapper.h @@ -0,0 +1,41 @@ +#ifndef NanoleafApiWrapper_h +#define NanoleafApiWrapper_h + +#include +#include +#include +#include + +class NanoleafApiWrapper { +public: + explicit NanoleafApiWrapper(const WiFiClient &wifiClient); + + void setup(const char *nanoleafBaseUrl, const char *nanoleafAuthToken); + + bool isConnected(); + + String generateToken(); + + bool identify(); + + std::vector getPanelIds(); + + bool setPower(const bool &state); + + bool setStaticColors(const JsonObject &doc); + +private: + bool sendRequest( + const String &method, + const String &endpoint, + const JsonDocument *requestBody, + JsonDocument *responseBody, + bool useAuthToken + ); + + String nanoleafBaseUrl; + String nanoleafAuthToken; + WiFiClient client; +}; + +#endif diff --git a/include/README b/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/include/TopicAdapter.h b/include/TopicAdapter.h new file mode 100644 index 0000000..be6be90 --- /dev/null +++ b/include/TopicAdapter.h @@ -0,0 +1,15 @@ +#ifndef TOPICADAPTER_H +#define TOPICADAPTER_H + +#include + +class TopicAdapter { +public: + virtual ~TopicAdapter() = default; + + [[nodiscard]] virtual const char *getTopic() const = 0; + + virtual void callback(char *topic, const JsonObject &payload, unsigned int length) = 0; +}; + +#endif diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..2593a33 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..5973365 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,10 @@ +[env:d1_mini] +platform = espressif8266 +board = d1_mini +framework = arduino +monitor_speed = 115200 +lib_deps = + knolleary/PubSubClient @ ^2.8 + bblanchon/ArduinoJson @ ^7.0.4 + tzapu/WiFiManager @ ^0.16.0 + robtillaart/UUID @ ^0.1.6 \ No newline at end of file diff --git a/src/GeoGlow-TileController.cpp b/src/GeoGlow-TileController.cpp new file mode 100644 index 0000000..288e10c --- /dev/null +++ b/src/GeoGlow-TileController.cpp @@ -0,0 +1,200 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "MQTTClient.h" +#include "NanoleafApiWrapper.h" + +#include "ColorPaletteAdapter.h" + +WiFiManager wifiManager; +WiFiClient wifiClient; +MQTTClient mqttClient(wifiClient); +NanoleafApiWrapper nanoleaf(wifiClient); + +ColorPaletteAdapter colorPaletteAdapter(nanoleaf); + +unsigned long lastPublishTime = 30000; + +char mqttBroker[40]; +char mqttPort[6] = "1883"; +char nanoleafBaseUrl[55] = ""; +char nanoleafAuthToken[33] = ""; +char friendId[36] = ""; +char deviceId[36] = ""; + +bool shouldSaveConfig = false; + +void saveConfigCallback() { + Serial.println("Should save config"); + shouldSaveConfig = true; +} + +void setup() { + Serial.begin(115200); + delay(10); + + UUID uuid; + + uint32_t seed1 = random(999999999); + uint32_t seed2 = random(999999999); + + uuid.seed(seed1, seed2); + uuid.generate(); + strcpy(deviceId, uuid.toCharArray()); + + + if (SPIFFS.begin()) { + Serial.println("mounted file system"); + if (SPIFFS.exists("/config.json")) { + Serial.println("reading config file"); + File configFile = SPIFFS.open("/config.json", "r"); + if (configFile) { + Serial.println("opened config file"); + size_t size = configFile.size(); + std::unique_ptr buf(new char[size]); + configFile.readBytes(buf.get(), size); + + JsonDocument jsonConfig; + deserializeJson(jsonConfig, buf.get()); + Serial.println("parsed json"); + strcpy(mqttBroker, jsonConfig["mqttBroker"]); + strcpy(mqttPort, jsonConfig["mqttPort"]); + strcpy(nanoleafBaseUrl, jsonConfig["nanoleafBaseUrl"]); + strcpy(nanoleafAuthToken, jsonConfig["nanoleafAuthToken"]); + strcpy(deviceId, jsonConfig["deviceId"]); + strcpy(friendId, jsonConfig["friendId"]); + } else { + Serial.println("failed to load json config"); + } + configFile.close(); + } else { + if (MDNS.begin("esp8266") && MDNS.queryService("nanoleafapi", "tcp") > 0) { + IPAddress ip = MDNS.IP(0); + uint16_t port = MDNS.port(0); + + snprintf( + nanoleafBaseUrl, + sizeof(nanoleafBaseUrl), + "http://%u.%u.%u.%u:%u", + ip[0], ip[1], ip[2], ip[3], port + ); + } + } + } else { + Serial.println("failed to mount FS"); + } + + WiFiManagerParameter customMqttBroker("mqttBroker", "mqtt broker", mqttBroker, 40); + WiFiManagerParameter customMqttPort("mqttPort", "mqtt port", mqttPort, 6); + WiFiManagerParameter customNanoleafBaseUrl( + "nanoleafBaseUrl", + "http://]>", + nanoleafBaseUrl, + 55 + ); + WiFiManagerParameter customFriendId("friendId", "", friendId, 36); + + wifiManager.setSaveConfigCallback(saveConfigCallback); + wifiManager.addParameter(&customMqttBroker); + wifiManager.addParameter(&customMqttPort); + wifiManager.addParameter(&customNanoleafBaseUrl); + wifiManager.addParameter(&customFriendId); + + if (!wifiManager.autoConnect("GeoGlow")) { + Serial.println("failed to connect and hit timeout"); + delay(3000); + EspClass::restart(); + delay(5000); + } + + Serial.println("connected...yeey :)"); + strcpy(mqttBroker, customMqttBroker.getValue()); + strcpy(mqttPort, customMqttPort.getValue()); + strcpy(nanoleafBaseUrl, customNanoleafBaseUrl.getValue()); + strcpy(friendId, customFriendId.getValue()); + + friendId[sizeof(friendId) - 1] = '\0'; + deviceId[sizeof(deviceId) - 1] = '\0'; + + Serial.println("The values in the file are: "); + Serial.println("\tmqttBroker : \t\t" + String(mqttBroker)); + Serial.println("\tmqttPort : \t\t" + String(mqttPort)); + Serial.println("\tnanoleafBaseUrl : \t" + String(nanoleafBaseUrl)); + Serial.println("\tnanoleafAuthToken : \t" + String(nanoleafAuthToken)); + Serial.println("\tfriendId : \t\t" + String(friendId)); + Serial.println("\tdeviceId : \t\t" + String(deviceId)); + + Serial.print("local ip: "); + Serial.println(WiFi.localIP()); + + nanoleaf.setup(nanoleafBaseUrl, nanoleafAuthToken); + delay(1000); + + while (true) { + Serial.print("Attempting Nanoleaf connection..."); + + if (nanoleaf.isConnected()) { + Serial.println("connected"); + break; + } + Serial.println("failed"); + + if (String newToken = nanoleaf.generateToken(); newToken != "") { + newToken.toCharArray(nanoleafAuthToken, sizeof nanoleafAuthToken); + nanoleaf.setup(nanoleafBaseUrl, nanoleafAuthToken); + shouldSaveConfig = true; + } + delay(5000); + } + + if (shouldSaveConfig) { + Serial.println("saving config"); + JsonDocument jsonConfig; + jsonConfig["mqttBroker"] = mqttBroker; + jsonConfig["mqttPort"] = mqttPort; + jsonConfig["nanoleafBaseUrl"] = nanoleafBaseUrl; + jsonConfig["nanoleafAuthToken"] = nanoleafAuthToken; + jsonConfig["friendId"] = friendId; + jsonConfig["deviceId"] = deviceId; + + File configFile = SPIFFS.open("/config.json", "w"); + if (!configFile) { + Serial.println("failed to open config file for writing"); + } + serializeJson(jsonConfig, Serial); + serializeJson(jsonConfig, configFile); + configFile.close(); + } + + mqttClient.setup(mqttBroker, String(mqttPort).toInt(), friendId, deviceId); + mqttClient.addTopicAdapter(&colorPaletteAdapter); + + nanoleaf.setPower(true); + delay(1500); + nanoleaf.setPower(false); +} + +void loop() { + mqttClient.loop(); + + if (millis() - lastPublishTime >= 30000) { + JsonDocument jsonPayload; + jsonPayload["friendId"] = friendId; + jsonPayload["deviceId"] = deviceId; + jsonPayload["panelIds"] = JsonArray(); + + for (const String &panelId: nanoleaf.getPanelIds()) { + jsonPayload["panelIds"].add(panelId); + } + + mqttClient.publish("GeoGlow/Friend-Service/ping", jsonPayload); + + lastPublishTime = millis(); + } +} diff --git a/src/MQTTClient.cpp b/src/MQTTClient.cpp new file mode 100644 index 0000000..e604f4b --- /dev/null +++ b/src/MQTTClient.cpp @@ -0,0 +1,140 @@ +#include "MQTTClient.h" + +std::vector MQTTClient::topicAdapters; + +MQTTClient::MQTTClient(WiFiClient &wifiClient) + : client(wifiClient) { +} + +void MQTTClient::setup(const char *mqttBroker, const int mqttPort, const char *friendId, const char *deviceId) { + client.setServer(mqttBroker, mqttPort); + + auto callback = [this](char *topic, const byte *payload, const unsigned int length) { + this->callback(topic, payload, length); + }; + + std::function function = callback; + + client.setCallback(callback); + client.setBufferSize(2048); + + this->friendId = friendId; + this->deviceId = deviceId; +} + +void MQTTClient::loop() { + if (!client.connected()) { + reconnect(); + } + client.loop(); +} + +void MQTTClient::reconnect() { + while (!client.connected()) { + Serial.print("Attempting MQTT connection..."); + auto mqttClientId = "GeoGlow-" + String(this->friendId) + "-" + String(this->deviceId); + if (client.connect(mqttClientId.c_str())) { + Serial.println("connected: " + mqttClientId); + for (const auto adapter: topicAdapters) { + client.subscribe(buildTopic(adapter)); + } + } else { + Serial.print("failed, rc="); + Serial.print(client.state()); + Serial.println(" try again in 5 seconds"); + delay(5000); + } + } +} + +void MQTTClient::publish(const char *topic, const JsonDocument &jsonPayload) { + if (client.connected()) { + char buffer[512]; + const size_t n = serializeJson(jsonPayload, buffer); + client.publish(topic, buffer, n); + } else { + Serial.println("MQTT client not connected. Unable to publish message."); + } +} + +char *MQTTClient::buildTopic(const TopicAdapter *adapter) const { + const size_t topicLength = strlen("GeoGlow/") + + strlen(friendId) + 1 + + strlen(deviceId) + 1 + + strlen(adapter->getTopic()) + 1; + + const auto topic = static_cast(malloc(topicLength)); + + if (topic == nullptr) { + return nullptr; + } + + strcpy(topic, "GeoGlow/"); + strcat(topic, friendId); + strcat(topic, "/"); + strcat(topic, deviceId); + strcat(topic, "/"); + strcat(topic, adapter->getTopic()); + + return topic; +} + + +void MQTTClient::addTopicAdapter(TopicAdapter *adapter) { + topicAdapters.push_back(adapter); + if (client.connected()) { + client.subscribe(buildTopic(adapter)); + } +} + +void MQTTClient::callback(char *topic, const byte *payload, const unsigned int length) const { + char payloadBuffer[length + 1]; + memcpy(payloadBuffer, payload, length); + payloadBuffer[length] = '\0'; + + JsonDocument jsonDocument; + + if (deserializeJson(jsonDocument, payloadBuffer)) { + Serial.print("Unhandled message ["); + Serial.print(topic); + Serial.print("] "); + Serial.println(payloadBuffer); + return; + } + + for (const auto adapter: topicAdapters) { + if (matches(buildTopic(adapter), topic)) { + adapter->callback(topic, jsonDocument.as(), length); + return; + } + } + + Serial.print("Unhandled message ["); + Serial.print(topic); + Serial.print("] "); + Serial.println(payloadBuffer); +} + +bool MQTTClient::matches(const char *subscribedTopic, const char *receivedTopic) { + if (const char *wildCardPos = strchr(subscribedTopic, '#'); wildCardPos != nullptr) { + if (wildCardPos[1] == '\0') { + size_t subscribedTopicLength = wildCardPos - subscribedTopic; + if (subscribedTopicLength > 0 && subscribedTopic[subscribedTopicLength - 1] == '/') { + subscribedTopicLength--; + } + return strncmp(subscribedTopic, receivedTopic, subscribedTopicLength) == 0; + } + return false; + } + + if (const char *plusPos = strchr(subscribedTopic, '+'); plusPos != nullptr) { + const char *slashPos = strchr(receivedTopic, '/'); + if (slashPos == nullptr) { + return true; + } + return strncmp(subscribedTopic, receivedTopic, plusPos - subscribedTopic) == 0 && + strcmp(plusPos + 1, slashPos + 1) == 0; + } + + return strcmp(subscribedTopic, receivedTopic) == 0; +} diff --git a/src/NanoleafApiWrapper.cpp b/src/NanoleafApiWrapper.cpp new file mode 100644 index 0000000..711e302 --- /dev/null +++ b/src/NanoleafApiWrapper.cpp @@ -0,0 +1,154 @@ +#include "NanoleafApiWrapper.h" + +NanoleafApiWrapper::NanoleafApiWrapper(const WiFiClient &wifiClient) + : client(wifiClient) { +} + + +void NanoleafApiWrapper::setup(const char *nanoleafBaseUrl, const char *nanoleafAuthToken) { + this->nanoleafBaseUrl = nanoleafBaseUrl; + this->nanoleafAuthToken = nanoleafAuthToken; +} + + +bool NanoleafApiWrapper::sendRequest(const String &method, const String &endpoint, const JsonDocument *requestBody, + JsonDocument *responseBody, const bool useAuthToken) { + if (WiFi.status() == WL_CONNECTED) { + HTTPClient http; + String url = nanoleafBaseUrl; + + if (useAuthToken) { + url += "/api/v1/" + nanoleafAuthToken; + } else { + url += "/api/v1"; + } + + url += endpoint; + + http.begin(client, url); + http.addHeader("Content-Type", "application/json"); + + int httpResponseCode = -1; + + if (method.equalsIgnoreCase("GET")) { + httpResponseCode = http.GET(); + } else if (method.equalsIgnoreCase("POST")) { + if (requestBody != nullptr) { + String stringPayload; + serializeJson(*requestBody, stringPayload); + httpResponseCode = http.POST(stringPayload); + } else { + httpResponseCode = http.POST(""); + } + } else if (method.equalsIgnoreCase("PUT")) { + if (requestBody != nullptr) { + String stringPayload; + serializeJson(*requestBody, stringPayload); + httpResponseCode = http.PUT(stringPayload); + } else { + httpResponseCode = http.PUT(""); + } + } + + if (httpResponseCode > 0) { + String response = http.getString(); + + if (responseBody != nullptr) { + deserializeJson(*responseBody, response); + } + + http.end(); + return true; + } + Serial.print("Error on sending "); + Serial.print(method); + Serial.print(": "); + Serial.println(httpResponseCode); + http.end(); + return false; + } + Serial.println("WiFi Disconnected"); + return false; +} + + +bool NanoleafApiWrapper::isConnected() { + JsonDocument jsonResponse; + if (sendRequest("GET", "/", nullptr, &jsonResponse, true)) { + if (jsonResponse["serialNo"] != nullptr) { + return true; + } + } + return false; +} + + +String NanoleafApiWrapper::generateToken() { + JsonDocument jsonResponse; + if (sendRequest("POST", "/new", nullptr, &jsonResponse, false)) { + if (const String strPayload = jsonResponse["auth_token"]; strPayload != nullptr && strPayload != "null") { + return strPayload; + } + } + return ""; +} + + +bool NanoleafApiWrapper::identify() { + return sendRequest("PUT", "/identify", nullptr, nullptr, true); +} + +std::vector NanoleafApiWrapper::getPanelIds() { + JsonDocument jsonResponse; + + std::vector panelIds; + + if (sendRequest("GET", "/panelLayout/layout", nullptr, &jsonResponse, true) && + jsonResponse["positionData"] != nullptr + ) { + const size_t arraySize = jsonResponse["positionData"].size(); + + for (size_t i = 0; i < arraySize; i++) { + if (auto panelId = jsonResponse["positionData"][i]["panelId"].as(); panelId != "0") { + panelIds.push_back(panelId); + } + } + } + + return panelIds; +} + + +bool NanoleafApiWrapper::setPower(const bool &state) { + JsonDocument jsonPayload; + jsonPayload["on"] = JsonObject(); + jsonPayload["on"]["value"] = state; + return sendRequest("PUT", "/state", &jsonPayload, nullptr, true); +} + + +bool NanoleafApiWrapper::setStaticColors(const JsonObject &doc) { + String animData = ""; + const unsigned int tileCount = doc.size(); + animData += String(tileCount) + " "; + + for (JsonPair kv: doc) { + String tileId = kv.key().c_str(); + auto rgb = kv.value().as(); + animData += tileId + " 2 " + String(rgb[0].as()) + " " + String(rgb[1].as()) + " " + + String(rgb[2].as()) + " 0 " + String(static_cast(floor(random(5, 50)))) + " 0 0 0 0 360 "; + } + + JsonDocument jsonPayload; + jsonPayload["write"] = JsonObject(); + jsonPayload["write"]["command"] = "display"; + jsonPayload["write"]["version"] = "2.0"; + jsonPayload["write"]["animType"] = "custom"; + jsonPayload["write"]["animData"] = animData; + jsonPayload["write"]["loop"] = false; + jsonPayload["write"]["palette"] = JsonArray(); + jsonPayload["write"]["palette"].add(JsonObject()); + jsonPayload["write"]["palette"][0]["hue"] = 0; + + return sendRequest("PUT", "/effects", &jsonPayload, nullptr, true); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html