From 214c05b231e7dc5234edd55baa508d1eaedd74bc Mon Sep 17 00:00:00 2001 From: Clyne Sullivan Date: Sun, 12 May 2024 09:22:35 -0400 Subject: [PATCH] tested new API; clean up .ino --- noisemeter-device/api.cpp | 206 ++++++++++++++++++++++++ noisemeter-device/api.h | 67 ++++++++ noisemeter-device/certs.py | 0 noisemeter-device/config.h.example | 3 + noisemeter-device/noisemeter-device.ino | 189 +++++----------------- noisemeter-device/ota-update.cpp | 47 +----- noisemeter-device/ota-update.h | 43 +---- noisemeter-device/storage.h | 3 +- noisemeter-device/url-encode.h | 51 ++++++ platformio.ini | 2 +- 10 files changed, 381 insertions(+), 230 deletions(-) create mode 100644 noisemeter-device/api.cpp create mode 100644 noisemeter-device/api.h mode change 100644 => 100755 noisemeter-device/certs.py create mode 100644 noisemeter-device/url-encode.h diff --git a/noisemeter-device/api.cpp b/noisemeter-device/api.cpp new file mode 100644 index 0000000..db3f624 --- /dev/null +++ b/noisemeter-device/api.cpp @@ -0,0 +1,206 @@ +/* noisemeter-device - Firmware for CivicTechTO's Noisemeter Device + * Copyright (C) 2024 Clyne Sullivan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "api.h" +#include "board.h" +#include "certs.h" +#include "url-encode.h" + +#include +#include + +#include + +API::Request::Request(const char endpoint[]) +{ + url.reserve(256); + url.concat(Base); + url.concat(endpoint); + url.concat('?'); +} + +API::Request& API::Request::addParam(const char param[], String value) +{ + url.concat(param); + url.concat('='); + url.concat(urlEncode(value)); + url.concat('&'); + return *this; +} + +std::optional API::sendAuthorizedRequest(const API::Request& req) +{ + WiFiClientSecure client; + client.setCACert(rootCertificate()); + +#ifdef API_VERBOSE + SERIAL.print("[api] Authorized request: "); + SERIAL.println(req.url); +#endif + + HTTPClient https; + if (https.begin(client, req.url)) { + https.addHeader("Authorization", String("Token ") + token); + + if (const auto code = https.GET(); code > 0) { + if (code == HTTP_CODE_OK || code == HTTP_CODE_MOVED_PERMANENTLY) { + const auto response = https.getString(); +#ifdef API_VERBOSE + SERIAL.print("[api] Response: "); + SERIAL.println(response); +#endif + https.end(); + + return responseToJson(response); +#ifdef API_VERBOSE + } else { + SERIAL.print("[api] HTTP error: "); + SERIAL.println(code); +#endif + } +#ifdef API_VERBOSE + } else { + SERIAL.print("[api] HTTP error: "); + SERIAL.println(code); +#endif + } +#ifdef API_VERBOSE + } else { + SERIAL.println("[api] Failed to https.begin()"); +#endif + } + + return {}; +} + +std::optional API::sendUnauthorizedRequest(const API::Request& req) +{ + WiFiClientSecure client; + client.setCACert(rootCertificate()); + +#ifdef API_VERBOSE + SERIAL.print("[api] Unauthorized request: "); + SERIAL.println(req.url); +#endif + + HTTPClient https; + if (https.begin(client, req.url)) { + if (const auto code = https.GET(); code > 0) { + if (code == HTTP_CODE_OK || code == HTTP_CODE_MOVED_PERMANENTLY) { + const auto response = https.getString(); +#ifdef API_VERBOSE + SERIAL.print("[api] Response: "); + SERIAL.println(response); +#endif + https.end(); + + return responseToJson(response); +#ifdef API_VERBOSE + } else { + SERIAL.print("[api] HTTP error: "); + SERIAL.println(code); +#endif + } +#ifdef API_VERBOSE + } else { + SERIAL.print("[api] HTTP error: "); + SERIAL.println(code); +#endif + } +#ifdef API_VERBOSE + } else { + SERIAL.println("[api] Failed to https.begin()"); +#endif + } + + return {}; +} + +std::optional API::responseToJson(const String& response) +{ + JsonDocument doc; + const auto error = deserializeJson(doc, response); + + if (error) { + SERIAL.println(error.f_str()); + return {}; + } else { + return doc; + } +} + +API::API(UUID id_, String token_): + id(id_), token(token_) {} + +bool API::sendMeasurement(const DataPacket& packet) +{ + const auto request = Request("measurement") + .addParam("device", id) + .addParam("timestamp", packet.timestamp) + .addParam("min", String(std::lround(packet.minimum))) + .addParam("max", String(std::lround(packet.maximum))) + .addParam("mean", String(std::lround(packet.average))); + + const auto resp = sendAuthorizedRequest(request); + return resp && (*resp)["result"] == "ok"; +} + +bool API::sendDiagnostics(String version, String boottime) +{ + const auto request = Request("measurement") + .addParam("device", id) + .addParam("version", version) + .addParam("boottime", boottime); + + const auto resp = sendAuthorizedRequest(request); + return resp && (*resp)["result"] == "ok"; +} + +std::optional API::sendRegister(String email) +{ + const auto request = Request("device/register") + .addParam("device", id) + .addParam("email", email); + + const auto resp = sendUnauthorizedRequest(request); + if (resp && (*resp)["result"] == "ok") + return (*resp)["token"]; + else + return {}; +} + +std::optional API::getLatestSoftware() +{ + const auto request = Request("software/latest"); + + const auto resp = sendUnauthorizedRequest(request); + if (resp && (*resp)["result"] == "ok") { + LatestSoftware ls = { + (*resp)["version"], + (*resp)["url"] + }; + + return ls; + } else { + return {}; + } +} + +const char *API::rootCertificate() +{ + return cert_ISRG_Root_X1; +} + diff --git a/noisemeter-device/api.h b/noisemeter-device/api.h new file mode 100644 index 0000000..4724b75 --- /dev/null +++ b/noisemeter-device/api.h @@ -0,0 +1,67 @@ +/// @file +/// @brief API implementation for communication with server +/* noisemeter-device - Firmware for CivicTechTO's Noisemeter Device + * Copyright (C) 2024 Clyne Sullivan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#ifndef API_H +#define API_H + +#include "data-packet.h" +#include "UUID/UUID.h" + +#include +#include + +#include + +class API +{ + constexpr static const char Base[] = "https://noisemeter.webcomand.com/api/v1/"; + + struct Request { + Request(const char endpoint[]); + + Request& addParam(const char param[], String value); + + String url; + }; + +public: + struct LatestSoftware { + String version; + String url; + }; + + API(UUID id_, String token_ = {}); + + bool sendMeasurement(const DataPacket& packet); + bool sendDiagnostics(String version, String boottime); + std::optional sendRegister(String email); + std::optional getLatestSoftware(); + + static const char *rootCertificate(); + +private: + UUID id; + String token; + + std::optional responseToJson(const String& response); + std::optional sendAuthorizedRequest(const Request& req); + std::optional sendUnauthorizedRequest(const Request& req); +}; + +#endif // API_H + diff --git a/noisemeter-device/certs.py b/noisemeter-device/certs.py old mode 100644 new mode 100755 diff --git a/noisemeter-device/config.h.example b/noisemeter-device/config.h.example index 4584af3..cf2528d 100644 --- a/noisemeter-device/config.h.example +++ b/noisemeter-device/config.h.example @@ -20,6 +20,9 @@ // Uncomment to print credentials over serial (for debugging). //#define STORAGE_SHOW_CREDENTIALS +// Uncomment to print verbose API logging over serial (for debugging). +//#define API_VERBOSE + // Define only *one* of the follwoing board options. // If using PlatformIO, the selected 'env' will override this selection. #if !defined(BOARD_ESP32_PCB) && \ diff --git a/noisemeter-device/noisemeter-device.ino b/noisemeter-device/noisemeter-device.ino index eaad012..7626a8d 100644 --- a/noisemeter-device/noisemeter-device.ino +++ b/noisemeter-device/noisemeter-device.ino @@ -16,29 +16,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -#include // https://arduinojson.org/ -#include -#include #include -#include #include #include #include "access-point.h" +#include "api.h" #include "blinker.h" #include "board.h" #include "data-packet.h" -#include "certs.h" -#include "secret.h" #include "spl-meter.h" #include "storage.h" #include "ota-update.h" #include "UUID/UUID.h" -#include #include #include -#include #include #if defined(BUILD_PLATFORMIO) && defined(BOARD_ESP32_PCB) @@ -69,14 +62,6 @@ static Timestamp lastUpload = Timestamp::invalidTimestamp(); /** Tracks when the last OTA update check occurred. */ static Timestamp lastOTACheck = Timestamp::invalidTimestamp(); -/** - * Outputs an array of floating-point values over serial. - * @deprecated Unused and unlikely to be used in the future - * @param arr The array to output - * @param length Number of values from arr to display - */ -void printArray(float arr[], unsigned long length); - /** * Outputs the given decibel reading over serial. * @param reading The decibel reading to display @@ -102,31 +87,6 @@ UUID buildDeviceId(); */ int tryWifiConnection(); -/** - * Creates a serialized JSON payload containing the given data. - * @param dp DataPacket containing the data to serialize - * @return String containing the resulting JSON payload - */ -String createJSONPayload(const DataPacket& dp); - -/** - * Upload a serialized JSON payload to our server. - * @param json JSON payload to be sent - * @return Zero on success or a negative number on failure - */ -int uploadData(String json); - -/** - * Prepares the I2S peripheral for reading microphone data. - */ -void initMicrophone(); - -/** - * Reads a set number of samples from the microphone and factors them into - * our decibel calculations and statistics. - */ -void readMicrophoneData(); - /** * Firmware entry point and initialization routine. */ @@ -138,10 +98,6 @@ void setup() { // Useful as a "reset" button to overwrite currently saved credentials. pinMode(PIN_BUTTON, INPUT_PULLUP); - // If needed, now you can actually lower the CPU frquency, - // i.e. if you want to (slightly) reduce ESP32 power consumption - // setCpuFrequencyMhz(80); // It should run as low as 80MHz - SERIAL.begin(115200); delay(2000); SERIAL.println(); @@ -180,6 +136,25 @@ void setup() { ap.run(); // does not return } + if (const auto email = Creds.get(Storage::Entry::Email); email.length() > 0) { + API api (buildDeviceId()); + const auto registration = api.sendRegister(email); + + if (registration) { + Creds.set(Storage::Entry::Email, {}); + Creds.set(Storage::Entry::Token, *registration); + Creds.commit(); + + SERIAL.print("Registered! "); + SERIAL.println(*registration); + } else { + SERIAL.println("Failed to register!"); + Creds.clear(); + delay(2000); + ESP.restart(); + } + } + Timestamp now; lastUpload = now; lastOTACheck = now; @@ -217,6 +192,8 @@ void loop() { } if (WiFi.status() == WL_CONNECTED) { + API api (buildDeviceId(), Creds.get(Storage::Entry::Token)); + { std::optional bl; @@ -224,13 +201,8 @@ void loop() { if (++packets.cbegin() != packets.cend()) bl.emplace(300); - packets.remove_if([](const auto& pkt) { - if (pkt.count > 0) { - const auto payload = createJSONPayload(pkt); - return uploadData(payload) == 0; - } else { - return true; // Discard empty packets - } + packets.remove_if([&api](const auto& pkt) { + return pkt.count <= 0 || api.sendMeasurement(pkt); }); } @@ -240,20 +212,24 @@ void loop() { lastOTACheck = now; SERIAL.println("Checking for updates..."); - OTAUpdate ota (cert_ISRG_Root_X1); - if (ota.available()) { - SERIAL.print(ota.version); - SERIAL.println(" available!"); - - if (ota.download()) { - SERIAL.println("Download success! Restarting..."); - delay(1000); - ESP.restart(); + const auto ota = api.getLatestSoftware(); + if (ota) { + if (ota->version.compareTo(NOISEMETER_VERSION) > 0) { + SERIAL.print(ota->version); + SERIAL.println(" available!"); + + if (downloadOTAUpdate(ota->url, api.rootCertificate())) { + SERIAL.println("Download success! Restarting..."); + delay(1000); + ESP.restart(); + } else { + SERIAL.println("Update download failed."); + } } else { - SERIAL.println("Update download failed."); + SERIAL.println("No new updates."); } } else { - SERIAL.println("No update available."); + SERIAL.println("Failed to reach update server!"); } } #endif // BOARD_ESP32_PCB @@ -277,16 +253,6 @@ void loop() { #endif // !UPLOAD_DISABLED } -void printArray(float arr[], unsigned long length) { - SERIAL.print(length); - SERIAL.print(" {"); - for (int i = 0; i < length; i++) { - SERIAL.print(arr[i]); - if (i < length - 1) SERIAL.print(", "); - } - SERIAL.println("}"); -} - void printReadingToConsole(double reading) { String output = ""; output += std::lround(reading); @@ -304,13 +270,15 @@ bool saveNetworkCreds(WebServer& httpServer) { if (httpServer.hasArg("ssid") && httpServer.hasArg("psk")) { const auto ssid = httpServer.arg("ssid"); const auto psk = httpServer.arg("psk"); + const auto email = httpServer.arg("email"); // Confirm that the given credentials will fit in the allocated EEPROM space. - if (!ssid.isEmpty() && Creds.canStore(ssid) && Creds.canStore(psk)) { + if (!ssid.isEmpty() && Creds.canStore(ssid) && Creds.canStore(psk) && Creds.canStore(email)) { Creds.set(Storage::Entry::SSID, ssid); Creds.set(Storage::Entry::Passkey, psk); - Creds.set(Storage::Entry::Token, API_TOKEN); + Creds.set(Storage::Entry::Email, email); Creds.commit(); + return true; } } @@ -347,74 +315,3 @@ int tryWifiConnection() return connected ? 0 : -1; } -String createJSONPayload(const DataPacket& dp) -{ -#ifdef BUILD_PLATFORMIO - JsonDocument doc; -#else - DynamicJsonDocument doc (2048); -#endif - - doc["parent"] = "/Bases/nm1"; - doc["data"]["type"] = "comand"; - doc["data"]["version"] = "1.0"; - doc["data"]["contents"][0]["Type"] = "Noise"; - doc["data"]["contents"][0]["Min"] = std::lround(dp.minimum); - doc["data"]["contents"][0]["Max"] = std::lround(dp.maximum); - doc["data"]["contents"][0]["Mean"] = std::lround(dp.average); - doc["data"]["contents"][0]["DeviceID"] = String(buildDeviceId()); - doc["data"]["contents"][0]["Timestamp"] = String(dp.timestamp); - - // Serialize JSON document - String json; - serializeJson(doc, json); - return json; -} - -// Given a serialized JSON payload, upload the data to webcomand -int uploadData(String json) -{ - WiFiClientSecure client; - HTTPClient https; - - client.setCACert(cert_ISRG_Root_X1); - - SERIAL.print("[HTTPS] begin...\n"); - if (https.begin(client, "https://noisemeter.webcomand.com/ws/put")) { - SERIAL.print("[HTTPS] POST...\n"); - - // start connection and send HTTP header - https.addHeader("Authorization", String("Token ") + API_TOKEN); - https.addHeader("Content-Type", "application/json"); - https.addHeader("Content-Length", String(json.length())); - https.addHeader("User-Agent", "ESP32"); - - int httpCode = https.POST(json); - - // httpCode will be negative on error - if (httpCode > 0) { - // HTTP header has been send and Server response header has been handled - SERIAL.printf("[HTTPS] POST... code: %d\n", httpCode); - - // file found at server - if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) { - String payload = https.getString(); - SERIAL.println(payload); - } else { - SERIAL.printf("[HTTPS] POST... failed, error: %s\n", https.errorToString(httpCode).c_str()); - return -1; - } - } else { - SERIAL.printf("[HTTPS] POST... failed, error: %s\n", https.errorToString(httpCode).c_str()); - return -1; - } - - https.end(); - } else { - SERIAL.printf("[HTTPS] Unable to connect\n"); - return -1; - } - - return 0; -} - diff --git a/noisemeter-device/ota-update.cpp b/noisemeter-device/ota-update.cpp index 6a2e276..5bb3070 100644 --- a/noisemeter-device/ota-update.cpp +++ b/noisemeter-device/ota-update.cpp @@ -21,56 +21,19 @@ #include #include #include +#include #include "board.h" -constexpr auto OTA_JSON_URL = "https://live.noisemeter.webcomand.com/updates/latest.json"; +static bool applyUpdate(WiFiClientSecure& client, int totalSize); -bool OTAUpdate::available() -{ - WiFiClientSecure client; - client.setCACert(rootCA); - - HTTPClient https; - if (https.begin(client, OTA_JSON_URL)) { - const auto code = https.GET(); - - if (code == HTTP_CODE_OK || code == HTTP_CODE_MOVED_PERMANENTLY) { - const auto response = client.readString(); - - JsonDocument doc; - const auto error = deserializeJson(doc, response); - if (!error) { - version = doc["Version"].as(); - if (version.compareTo(NOISEMETER_VERSION) > 0) { - url = doc["URL"].as(); - return !url.isEmpty(); - } else { - SERIAL.print("Server version: "); - SERIAL.println(version); - } - } else { - SERIAL.print("json failed: "); - SERIAL.println(error.f_str()); - } - } else { - SERIAL.print("Bad HTTP response: "); - SERIAL.println(code); - } - } else { - SERIAL.println("Unable to connect."); - } - - return false; -} - -bool OTAUpdate::download() +bool downloadOTAUpdate(String url, String rootCA) { if (url.isEmpty()) return false; WiFiClientSecure client; - client.setCACert(rootCA); + client.setCACert(rootCA.c_str()); HTTPClient https; if (https.begin(client, url)) { @@ -89,7 +52,7 @@ bool OTAUpdate::download() return false; } -bool OTAUpdate::applyUpdate(WiFiClientSecure& client, int totalSize) +bool applyUpdate(WiFiClientSecure& client, int totalSize) { std::array buffer; diff --git a/noisemeter-device/ota-update.h b/noisemeter-device/ota-update.h index b5f8de0..c1f9713 100644 --- a/noisemeter-device/ota-update.h +++ b/noisemeter-device/ota-update.h @@ -20,49 +20,12 @@ #define OTA_UPDATE_H #include -#include /** - * Manages fetching and installing of OTA software updates. + * Downloads and applies the latest OTA update. + * @return True if update is successfully downloaded and installed. */ -struct OTAUpdate -{ - /** Stores the latest version string from the server (for logging). */ - String version; - - /** - * Creates an OTAUpdate object. - * @param rootCA_ Root certificate to use for HTTPS requests. - */ - OTAUpdate(const char *rootCA_): - rootCA(rootCA_) {} - - /** - * Checks if a new OTA update is available. - * @return True if available. - */ - bool available(); - - /** - * Downloads and applies the latest OTA update. - * @return True if update is successfully downloaded and installed. - */ - bool download(); - -private: - /** Stores fetched URL for the latest update. */ - String url; - /** Stores the given root certificate for HTTPS. */ - const char *rootCA; - - /** - * Writes the received OTA update to flash memory. - * @param client An active client for the update download. - * @param totalSize The total size of the update if known. - * @return True if update is successfully installed. - */ - bool applyUpdate(WiFiClientSecure& client, int totalSize); -}; +bool downloadOTAUpdate(String url, String rootCA); #endif // OTA_UPDATE_H diff --git a/noisemeter-device/storage.h b/noisemeter-device/storage.h index 668e555..a1ad587 100644 --- a/noisemeter-device/storage.h +++ b/noisemeter-device/storage.h @@ -41,7 +41,8 @@ class Storage : protected EEPROMClass SSID = Checksum + sizeof(uint32_t), /** User's WiFi SSID */ Passkey = SSID + StringSize, /** User's WiFi passkey */ Token = Passkey + StringSize, /** Device API token */ - TotalSize = Token + StringSize /** Marks storage end address */ + Email = Token + StringSize, /** Temporary storage of user's email */ + TotalSize = Email + StringSize /** Marks storage end address */ }; /** diff --git a/noisemeter-device/url-encode.h b/noisemeter-device/url-encode.h new file mode 100644 index 0000000..ee09e9d --- /dev/null +++ b/noisemeter-device/url-encode.h @@ -0,0 +1,51 @@ +/* MIT License + * + * Copyright (c) 2022 Masayuki Sugahara + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software 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 Software. + * + * THE SOFTWARE 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 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +#ifndef URL_ENCODE_H +#define URL_ENCODE_H + +#include + +String urlEncode(const char *msg) { + const char *hex = "0123456789ABCDEF"; + String encodedMsg = ""; + + while (*msg != '\0') { + if ( + ('a' <= *msg && *msg <= 'z') || ('A' <= *msg && *msg <= 'Z') || ('0' <= *msg && *msg <= '9') || *msg == '-' || *msg == '_' || *msg == '.' || *msg == '~') { + encodedMsg += *msg; + } else { + encodedMsg += '%'; + encodedMsg += hex[(unsigned char)*msg >> 4]; + encodedMsg += hex[*msg & 0xf]; + } + msg++; + } + return encodedMsg; +} + +String urlEncode(String msg) { + return urlEncode(msg.c_str()); +} + +#endif // URL_ENCODE_H + diff --git a/platformio.ini b/platformio.ini index d41ecbc..993d5e9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,7 +26,7 @@ build_flags = -std=gnu++17 -DBUILD_PLATFORMIO -DNO_GLOBAL_EEPROM - -DNOISEMETER_VERSION=\"0.0.4\" + -DNOISEMETER_VERSION=\"0.1.0\" -Wall -Wextra [env:esp32-pcb]