diff --git a/CMakeLists.txt b/CMakeLists.txt index 345446a..536afdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,7 @@ set(srcs "src/airgradientClient.cpp" "src/airgradientCellularClient.cpp" "src/airgradientWifiClient.cpp" + "src/endpointSelector.cpp" "src/atCommandHandler.cpp" "src/cellularModule.cpp" "src/cellularModuleA7672xx.cpp" diff --git a/src/airgradientWifiClient.cpp b/src/airgradientWifiClient.cpp index f53f427..6dbcdca 100644 --- a/src/airgradientWifiClient.cpp +++ b/src/airgradientWifiClient.cpp @@ -48,6 +48,8 @@ #ifdef ARDUINO #include +#include +#include "common.h" #else #include "esp_http_client.h" #endif @@ -146,20 +148,41 @@ bool AirgradientWifiClient::httpPostMeasures(const AirgradientPayload &payload) bool AirgradientWifiClient::_httpGet(const std::string &url, int &responseCode, std::string &responseBody) { #ifdef ARDUINO - // Init http client - HTTPClient client; - client.setConnectTimeout(timeoutMs); // Set timeout when establishing connection to server - client.setTimeout(timeoutMs); // Timeout when waiting for response from AG server - // By default, airgradient using https - if (client.begin(String(url.c_str()), AG_SERVER_ROOT_CA) == false) { - AG_LOGE(TAG, "Failed begin HTTPClient using TLS"); + _ensureSelectorReady(); + std::string path = _extractPath(url); + if (path.empty()) { + AG_LOGE(TAG, "Could not extract path from URL: %s", url.c_str()); return false; } + const char *host = httpDomain.c_str(); + + // Phase 1: try every resolved IP exactly once, in cursor order. The + // cursor stays put on success (sticky) and advances on failure. + bool ipsExhausted = false; + if (selectorReady_ && selector_.count() > 0) { + const uint8_t total = selector_.count(); + for (uint8_t i = 0; i < total; ++i) { + IPAddress ip = selector_.current(); + if (_httpGetSecure(ip, host, path, responseCode, responseBody)) { + return true; + } + selector_.advance(); + } + ipsExhausted = true; + AG_LOGW(TAG, "All %u IP(s) failed; falling back to hostname resolution", total); + } - responseCode = client.GET(); - responseBody = client.getString().c_str(); - client.end(); - return true; + // Phase 2: hostname fallback. Same WiFiClientSecure + HTTPClient API, + // just let WiFiClientSecure do its own DNS resolution. + if (_httpGetSecure(IPAddress(static_cast(0)), host, path, responseCode, responseBody)) { + if (ipsExhausted) { + AG_LOGI(TAG, "Hostname fallback succeeded after IP exhaustion; refreshing DNS"); + selector_.refresh(); + } + return true; + } + AG_LOGE(TAG, "Both IP and hostname paths failed"); + return false; #else esp_http_client_config_t config = {}; config.url = url.c_str(); @@ -196,19 +219,40 @@ bool AirgradientWifiClient::_httpGet(const std::string &url, int &responseCode, bool AirgradientWifiClient::_httpPost(const std::string &url, const std::string &payload, int &responseCode) { #ifdef ARDUINO - HTTPClient client; - client.setConnectTimeout(timeoutMs); // Set timeout when establishing connection to server - client.setTimeout(timeoutMs); // Timeout when waiting for response from AG server - // By default, airgradient using https - if (client.begin(String(url.c_str()), AG_SERVER_ROOT_CA) == false) { - AG_LOGE(TAG, "Failed begin HTTPClient using TLS"); + _ensureSelectorReady(); + std::string path = _extractPath(url); + if (path.empty()) { + AG_LOGE(TAG, "Could not extract path from URL: %s", url.c_str()); return false; } + const char *host = httpDomain.c_str(); + + // Phase 1: try every resolved IP exactly once, in cursor order. The + // cursor stays put on success (sticky) and advances on failure. + bool ipsExhausted = false; + if (selectorReady_ && selector_.count() > 0) { + const uint8_t total = selector_.count(); + for (uint8_t i = 0; i < total; ++i) { + IPAddress ip = selector_.current(); + if (_httpPostSecure(ip, host, path, payload, responseCode)) { + return true; + } + selector_.advance(); + } + ipsExhausted = true; + AG_LOGW(TAG, "All %u IP(s) failed; falling back to hostname resolution", total); + } - client.addHeader("content-type", "application/json"); - responseCode = client.POST(String(payload.c_str())); - client.end(); - return true; + // Phase 2: hostname fallback via the same WiFiClientSecure API. + if (_httpPostSecure(IPAddress(static_cast(0)), host, path, payload, responseCode)) { + if (ipsExhausted) { + AG_LOGI(TAG, "Hostname fallback succeeded after IP exhaustion; refreshing DNS"); + selector_.refresh(); + } + return true; + } + AG_LOGE(TAG, "Both IP and hostname paths failed"); + return false; #else esp_http_client_config_t config = {}; config.url = url.c_str(); @@ -317,4 +361,143 @@ void AirgradientWifiClient::_serialize(JsonDocument &doc, const PayloadBuffer &p } } +#ifdef ARDUINO + +bool AirgradientWifiClient::_httpGetSecure(const IPAddress &ip, const char *host, + const std::string &path, int &responseCode, + std::string &responseBody) { + const bool usingIp = (static_cast(ip) != 0); + // Note: must extend the String lifetime to function scope; cannot use + // `ip.toString().c_str()` directly (temporary gets destroyed). + String ipStr = usingIp ? ip.toString() : String(); + const char *target = usingIp ? ipStr.c_str() : host; + + WiFiClientSecure secClient; + secClient.setCACert(AG_SERVER_ROOT_CA); + // setTimeout() expects seconds. + secClient.setTimeout((timeoutMs + 500) / 1000); + + // When we have a selected IP: 6-arg overload connects TCP to `ip` but + // SNI + cert verification still use `host`. When we don't: let + // WiFiClientSecure resolve `host` itself (which internally calls the + // same 6-arg overload with the resolved IP). + uint32_t t0 = MILLIS(); + int connectRet; + if (usingIp) { + connectRet = secClient.connect(ip, 443, host, AG_SERVER_ROOT_CA, nullptr, nullptr); + } else { + connectRet = secClient.connect(host, 443, AG_SERVER_ROOT_CA, nullptr, nullptr); + } + uint32_t connectDt = MILLIS() - t0; + if (connectRet != 1) { + AG_LOGW(TAG, "TLS connect to %s failed in %ums (ret=%d)", target, connectDt, connectRet); + return false; + } + AG_LOGI(TAG, "TLS up to %s in %ums", target, connectDt); + + HTTPClient client; + client.setConnectTimeout(timeoutMs); + client.setTimeout(timeoutMs); + // begin(WiFiClient&, host, port, uri, https) reuses the already-connected + // socket and sets the Host header from `host`. + if (client.begin(secClient, host, 443, path.c_str(), true) == false) { + AG_LOGE(TAG, "Failed begin HTTPClient on pre-connected client"); + secClient.stop(); + return false; + } + + responseCode = client.GET(); + if (responseCode <= 0) { + AG_LOGW(TAG, "HTTP GET via %s failed: %d (%s)", target, responseCode, + HTTPClient::errorToString(responseCode).c_str()); + client.end(); + return false; + } + responseBody = client.getString().c_str(); + client.end(); + return true; +} + +bool AirgradientWifiClient::_httpPostSecure(const IPAddress &ip, const char *host, + const std::string &path, const std::string &payload, + int &responseCode) { + const bool usingIp = (static_cast(ip) != 0); + String ipStr = usingIp ? ip.toString() : String(); + const char *target = usingIp ? ipStr.c_str() : host; + + WiFiClientSecure secClient; + secClient.setCACert(AG_SERVER_ROOT_CA); + secClient.setTimeout((timeoutMs + 500) / 1000); + + uint32_t t0 = MILLIS(); + int connectRet; + if (usingIp) { + connectRet = secClient.connect(ip, 443, host, AG_SERVER_ROOT_CA, nullptr, nullptr); + } else { + connectRet = secClient.connect(host, 443, AG_SERVER_ROOT_CA, nullptr, nullptr); + } + uint32_t connectDt = MILLIS() - t0; + if (connectRet != 1) { + AG_LOGW(TAG, "TLS connect to %s failed in %ums (ret=%d)", target, connectDt, connectRet); + return false; + } + AG_LOGI(TAG, "TLS up to %s in %ums", target, connectDt); + + HTTPClient client; + client.setConnectTimeout(timeoutMs); + client.setTimeout(timeoutMs); + if (client.begin(secClient, host, 443, path.c_str(), true) == false) { + AG_LOGE(TAG, "Failed begin HTTPClient on pre-connected client"); + secClient.stop(); + return false; + } + client.addHeader("content-type", "application/json"); + + responseCode = client.POST(String(payload.c_str())); + if (responseCode <= 0) { + AG_LOGW(TAG, "HTTP POST via %s failed: %d (%s)", target, responseCode, + HTTPClient::errorToString(responseCode).c_str()); + client.end(); + return false; + } + client.end(); + return true; +} + +void AirgradientWifiClient::_ensureSelectorReady() { + // Re-initialize on first call or if the domain changed at runtime + // (e.g. setHttpDomain() was called by the application). + bool domainChanged = (selectorHost_ != httpDomain); + + if (!selectorReady_ || domainChanged) { + if (domainChanged && selectorReady_) { + AG_LOGI(TAG, "HTTP domain changed (%s -> %s); re-initializing selector", + selectorHost_.c_str(), httpDomain.c_str()); + } + selectorReady_ = selector_.begin(httpDomain.c_str()); + if (selectorReady_) { + selectorHost_ = httpDomain; + } else { + AG_LOGW(TAG, "Selector init failed for %s; will use domain-based path only", + httpDomain.c_str()); + } + return; + } + + // Periodic refresh (1h default). No-op if interval not yet elapsed. + selector_.maybeRefresh(MILLIS()); +} + +std::string AirgradientWifiClient::_extractPath(const std::string &url) const { + // Expected format: "https:///". We strip the prefix + // strictly so we don't accidentally hit a wrong path on malformed URLs. + std::string prefix = "https://" + httpDomain; + if (url.compare(0, prefix.size(), prefix) != 0) { + return std::string(); + } + return url.substr(prefix.size()); +} + +#endif // ARDUINO + #endif // ESP8266 diff --git a/src/airgradientWifiClient.h b/src/airgradientWifiClient.h index 1a43faf..f0aaff4 100644 --- a/src/airgradientWifiClient.h +++ b/src/airgradientWifiClient.h @@ -15,6 +15,9 @@ #include #include "airgradientClient.h" +#ifdef ARDUINO +#include "endpointSelector.h" +#endif #ifndef ARDUINO #define MAX_RESPONSE_BUFFER 2048 @@ -27,6 +30,11 @@ class AirgradientWifiClient : public AirgradientClient { #ifndef ARDUINO char responseBuffer[2048]; #endif +#ifdef ARDUINO + EndpointSelector selector_; + bool selectorReady_ = false; + std::string selectorHost_; +#endif public: AirgradientWifiClient() {}; ~AirgradientWifiClient() {}; @@ -41,6 +49,26 @@ class AirgradientWifiClient : public AirgradientClient { bool _httpPost(const std::string &url, const std::string &payload, int &responseCode); void _serialize(JsonDocument &doc, const PayloadBuffer &payload); +#ifdef ARDUINO + // Unified HTTPS GET/POST helpers used by both the IP-failover loop and + // the hostname fallback. If `ip` is 0.0.0.0, the WiFiClientSecure will + // resolve `host` via lwIP itself; otherwise TCP connects to `ip` while + // SNI + cert validation still use `host`. Return false on any + // transport/TLS failure or negative HTTP response code; true on any + // valid HTTP response (status code stored in responseCode). + bool _httpGetSecure(const IPAddress &ip, const char *host, const std::string &path, + int &responseCode, std::string &responseBody); + bool _httpPostSecure(const IPAddress &ip, const char *host, const std::string &path, + const std::string &payload, int &responseCode); + + // Ensure selector is initialized and tracks the current httpDomain. + // Performs lazy first-time init and periodic refresh. + void _ensureSelectorReady(); + + // Extract path portion from a full URL ("https://host/path" -> "/path"). + // Returns empty string if the URL does not match the expected format. + std::string _extractPath(const std::string &url) const; +#endif }; #endif // ESP8266 diff --git a/src/atCommandHandler.cpp b/src/atCommandHandler.cpp index ffb012c..9340dcd 100644 --- a/src/atCommandHandler.cpp +++ b/src/atCommandHandler.cpp @@ -46,7 +46,12 @@ void ATCommandHandler::sendRaw(const char *raw) { } void ATCommandHandler::sendRaw(const char *buf, int size) { - agSerial_->write(reinterpret_cast(buf), size); +#ifdef ARDUINO + // AgSerial::write takes const char*, AirgradientSerial (ESP-IDF) takes const uint8_t* + agSerial_->write(buf, size); +#else + agSerial_->write(reinterpret_cast(buf), size); +#endif agSerial_->print("\r\n"); AT_YIELD(); } diff --git a/src/endpointSelector.cpp b/src/endpointSelector.cpp new file mode 100644 index 0000000..9249ed0 --- /dev/null +++ b/src/endpointSelector.cpp @@ -0,0 +1,441 @@ +/** + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#ifndef ESP8266 +#ifdef ARDUINO + +#include "endpointSelector.h" + +#include +#include +#include +#include + +#include "agLogger.h" +#include "common.h" + +static const char *const TAG = "AgEpSel"; + +namespace { + +constexpr uint16_t DNS_PORT = 53; +constexpr uint32_t DNS_QUERY_TIMEOUT_MS = 5000; +constexpr size_t DNS_MAX_PACKET_SIZE = 512; // standard UDP DNS +constexpr uint16_t DNS_TYPE_A = 1; +constexpr uint16_t DNS_CLASS_IN = 1; +constexpr uint16_t DNS_TYPE_CNAME = 5; + +inline uint16_t rd16_(const uint8_t *buf, size_t off) { + return (static_cast(buf[off]) << 8) | buf[off + 1]; +} + +} // namespace + +bool EndpointSelector::begin(const char *hostname) { + if (hostname == nullptr || hostname[0] == '\0') { + return false; + } + strncpy(hostname_, hostname, sizeof(hostname_) - 1); + hostname_[sizeof(hostname_) - 1] = '\0'; + + if (!resolveAndStore_()) { + return false; + } + shuffle_(); + currentIdx_ = 0; + lastRefreshMs_ = MILLIS(); + logState_("ready"); + return true; +} + +IPAddress EndpointSelector::current() const { + if (count_ == 0) { + return IPAddress(static_cast(0)); + } + return ips_[currentIdx_]; +} + +void EndpointSelector::advance() { + if (count_ == 0) { + return; + } + uint8_t prevIdx = currentIdx_; + currentIdx_ = (currentIdx_ + 1) % count_; + AG_LOGW(TAG, "Advance %s -> %s (idx %u -> %u)", + ips_[prevIdx].toString().c_str(), + ips_[currentIdx_].toString().c_str(), prevIdx, currentIdx_); +} + +bool EndpointSelector::maybeRefresh(uint32_t nowMs) { + if (count_ == 0) { + // Never initialized or last refresh failed; let begin()/refresh() run. + return false; + } + if ((nowMs - lastRefreshMs_) < REFRESH_INTERVAL_MS) { + return false; + } + return refresh(); +} + +bool EndpointSelector::refresh() { + AG_LOGI(TAG, "Refreshing DNS list for %s", hostname_); + IPAddress active = current(); + + // Save the old list so we can restore on resolution failure. + uint8_t oldCount = count_; + IPAddress oldIps[MAX_IPS]; + for (uint8_t i = 0; i < oldCount; i++) { + oldIps[i] = ips_[i]; + } + + if (!resolveAndStore_()) { + AG_LOGW(TAG, "Refresh failed; keeping previous list"); + count_ = oldCount; + for (uint8_t i = 0; i < oldCount; i++) { + ips_[i] = oldIps[i]; + } + lastRefreshMs_ = MILLIS(); + return false; + } + + shuffle_(); + + // Try to keep the previously-active IP if it is still present. + bool stillThere = false; + if ((uint32_t)active != 0) { + for (uint8_t i = 0; i < count_; i++) { + if (ips_[i] == active) { + if (i != 0) { + IPAddress tmp = ips_[0]; + ips_[0] = ips_[i]; + ips_[i] = tmp; + } + stillThere = true; + break; + } + } + } + currentIdx_ = 0; + if (!stillThere && (uint32_t)active != 0) { + AG_LOGW(TAG, "Previously active IP %s no longer in DNS", + active.toString().c_str()); + } + lastRefreshMs_ = MILLIS(); + logState_("after-refresh"); + return true; +} + +/* ---------- private helpers ---------- */ + +uint8_t EndpointSelector::resolveAll_(IPAddress *out, uint8_t maxOut) { + if (out == nullptr || maxOut == 0) { + return 0; + } + + IPAddress dnsServer = WiFi.dnsIP(0); + if (static_cast(dnsServer) == 0) { + AG_LOGW(TAG, "No DNS server configured; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + AG_LOGI(TAG, "Querying DNS %s for %s (A)", dnsServer.toString().c_str(), hostname_); + + // ---- Build query ---- + uint8_t query[DNS_MAX_PACKET_SIZE]; + uint16_t txnId = 0; + size_t queryLen = buildQuery_(hostname_, query, sizeof(query), txnId); + if (queryLen == 0) { + AG_LOGE(TAG, "DNS query build failed"); + return 0; + } + + // ---- Send + receive ---- + WiFiUDP udp; + if (!udp.begin(0)) { // 0 = any local port + AG_LOGW(TAG, "UDP begin failed; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + + bool sent = udp.beginPacket(dnsServer, DNS_PORT); + if (sent) { + udp.write(query, queryLen); + sent = udp.endPacket(); + } + if (!sent) { + AG_LOGW(TAG, "DNS UDP send failed"); + udp.stop(); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + + uint32_t deadline = MILLIS() + DNS_QUERY_TIMEOUT_MS; + int pktSize = 0; + while ((pktSize = udp.parsePacket()) == 0) { + if (static_cast(MILLIS() - deadline) >= 0) { + AG_LOGW(TAG, "DNS response timeout"); + udp.stop(); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + DELAY_MS(5); + } + + uint8_t response[DNS_MAX_PACKET_SIZE]; + int respLen = udp.read(response, sizeof(response)); + udp.stop(); + + if (respLen <= 0) { + AG_LOGW(TAG, "DNS UDP read returned no data"); + return 0; + } + AG_LOGI(TAG, "DNS response %d bytes", respLen); + + uint8_t count = + parseAnswers_(response, static_cast(respLen), txnId, out, maxOut); + if (count == 0) { + AG_LOGW(TAG, "Raw DNS parse found 0 A records; falling back to hostByName"); + IPAddress ip; + if (WiFi.hostByName(hostname_, ip)) { + out[0] = ip; + return 1; + } + return 0; + } + return count; +} + +/* ---------- Raw DNS helpers (RFC 1035) ---------- */ + +size_t EndpointSelector::buildQuery_(const char *hostname, uint8_t *out, size_t outMax, + uint16_t &txnIdOut) { + if (outMax < 12 + 5) return 0; // header + minimal question + + uint16_t txnId = static_cast(esp_random() & 0xFFFF); + txnIdOut = txnId; + + // Header (12 bytes) + out[0] = (txnId >> 8) & 0xFF; + out[1] = txnId & 0xFF; + out[2] = 0x01; // flags: standard query, RD=1 + out[3] = 0x00; + out[4] = 0x00; + out[5] = 0x01; // QDCOUNT = 1 + out[6] = 0x00; + out[7] = 0x00; // ANCOUNT = 0 + out[8] = 0x00; + out[9] = 0x00; // NSCOUNT = 0 + out[10] = 0x00; + out[11] = 0x00; // ARCOUNT = 0 + size_t pos = 12; + + size_t nameLen = encodeName_(hostname, out + pos, outMax - pos); + if (nameLen == 0) return 0; + pos += nameLen; + + if (pos + 4 > outMax) return 0; + out[pos++] = 0x00; + out[pos++] = 0x01; // QTYPE = A + out[pos++] = 0x00; + out[pos++] = 0x01; // QCLASS = IN + + return pos; +} + +size_t EndpointSelector::encodeName_(const char *hostname, uint8_t *out, size_t outMax) { + size_t pos = 0; + const char *s = hostname; + while (*s != '\0') { + const char *dot = strchr(s, '.'); + size_t labelLen = dot ? static_cast(dot - s) : strlen(s); + if (labelLen == 0 || labelLen > 63) return 0; + if (pos + 1 + labelLen + 1 > outMax) return 0; // length + label + trailing root + out[pos++] = static_cast(labelLen); + memcpy(out + pos, s, labelLen); + pos += labelLen; + s += labelLen; + if (*s == '.') s++; + } + if (pos + 1 > outMax) return 0; + out[pos++] = 0; // root label + return pos; +} + +size_t EndpointSelector::skipName_(const uint8_t *buf, size_t bufLen, size_t offset) { + // Names are either a sequence of