├── .get_version.py ├── .gitignore ├── .prepare_release ├── .travis.yml ├── LICENSE ├── README.md ├── circuit.fzz ├── lib ├── HTTP │ ├── ThermometerWebserver.cpp │ └── ThermometerWebserver.h ├── Helpers │ └── IntParsing.h ├── MQTT │ ├── MqttClient.cpp │ └── MqttClient.h ├── Settings │ ├── Settings.cpp │ └── Settings.h ├── Sha │ ├── HmacHelpers.cpp │ ├── HmacHelpers.h │ ├── sha1.cpp │ └── sha1.h ├── TempIface │ ├── TempIface.cpp │ └── TempIface.h ├── TokenParsing │ ├── TokenIterator.cpp │ ├── TokenIterator.h │ ├── UrlTokenBindings.cpp │ └── UrlTokenBindings.h └── WebStrings │ ├── IndexPage.h │ ├── Javascript.h │ └── Stylesheet.h ├── platformio.ini └── src └── main.cpp /.get_version.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_output 2 | import sys 3 | import os 4 | import platform 5 | import subprocess 6 | 7 | dir_path = os.path.dirname(os.path.realpath(__file__)) 8 | os.chdir(dir_path) 9 | 10 | # http://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script 11 | def is_tool(name): 12 | cmd = "where" if platform.system() == "Windows" else "which" 13 | try: 14 | check_output([cmd, "git"]) 15 | return True 16 | except: 17 | return False 18 | 19 | version = "UNKNOWN" 20 | 21 | if is_tool("git"): 22 | try: 23 | version = check_output(["git", "describe", "--always"]).rstrip() 24 | except: 25 | try: 26 | version = check_output(["git", "rev-parse", "--short", "HEAD"]).rstrip() 27 | except: 28 | pass 29 | pass 30 | 31 | sys.stdout.write("-DESP8266_THERMOMETER_VERSION=%s %s" % (version.decode('utf-8'), ' '.join(sys.argv[1:]))) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pioenvs 2 | .piolibdeps 3 | .pio 4 | .clang_complete 5 | .gcc-flags.json 6 | lib/readme.txt 7 | .vscode 8 | -------------------------------------------------------------------------------- /.prepare_release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | PROJECT_NAME="esp8266_thermometer" 6 | 7 | prepare_log() { 8 | echo "[prepare release] -- $@" 9 | } 10 | 11 | if [ -z "$(git tag -l --points-at HEAD)" ]; then 12 | prepare_log "Skipping non-tagged commit." 13 | exit 0 14 | fi 15 | 16 | VERSION=$(git describe) 17 | 18 | prepare_log "Preparing release for tagged version: $VERSION" 19 | 20 | mkdir -p dist 21 | 22 | if [ -d .pio/build ]; then 23 | firmware_prefix=".pio/build" 24 | else 25 | firmware_prefix=".pioenvs" 26 | fi 27 | 28 | for file in $(ls ${firmware_prefix}/**/firmware.bin); do 29 | env_dir=$(dirname "$file") 30 | env=$(basename "$env_dir") 31 | 32 | cp "$file" "dist/${PROJECT_NAME}_${env}-${VERSION}.bin" 33 | done 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | sudo: false 5 | cache: 6 | directories: 7 | - "~/.platformio" 8 | install: 9 | - pip install -U platformio 10 | - platformio lib install 11 | script: 12 | - platformio run 13 | before_deploy: 14 | - "./.prepare_release" 15 | deploy: 16 | provider: releases 17 | prerelease: true 18 | api_key: 19 | secure: cejQW325lhBBvRy8aaW8SkLAB4vZ+0vo1RKAqRZJkJK9a742+WwG5eDipECaX5KgKYy5DkOyvJXPodMKThj1NzOlr6dclsGgE2F1qFQ8+HP8LloGJqAKahErlyxamGaY9AippkR/yf2YFW85EhTM5qhAiPq2bO2ln0Z9SucYz087/8wtnINVgPTrvioOJDB25oh1+OgUJmcMfN43EThJ8VjwVowXqdvRGJQ/VbsS+cBBlZyxxHTVTrMpV4xMVqT5dtFB1Ny8IGSg3myz3Hs89GC00h1jjipXewKVwDMkZUYVfgxaSfkzkm88I0Q9E9p8+GlL2IZQ9ly2KsmKLXynNq60KuiVIuFkI+6V4gajG7gxNFK9efzJ3zfTRsutxckmj3lDjC+vUnMPo2KlLk/Ko/2DCpLFSlrO0x5aq+LrwF+3UNZFFmpvuG/UtyHod5/3UCa2IhRfQUerFFD7mWpSrA3vvZEz+XmWVXx8VkSBN1HIejhN8h+q0goU+lAAywiQppfYk5R+W2p+vXXsenFdw+hwG5zrG0p7pnvvVnXgJl8yyHRMIFin94zsyAM8BaCwNQ7zbEIy8faU24OxofNx4qalf+xs7VkcVYrDOYwtj+Or3ZKrrPVCRnsC4DsfO5g4jeuJDvMaZmk6QB6eif0Bn3u1iE8e0Ygm6L1RxJqw8pc= 20 | file_glob: true 21 | skip_cleanup: true 22 | file: dist/*.bin 23 | on: 24 | repo: sidoh/esp8266_thermometer 25 | tags: true 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Mullins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thermometer [![Build Status](https://travis-ci.org/sidoh/esp8266_thermometer.svg?branch=master)](https://travis-ci.org/sidoh/esp8266_thermometer) [![release](https://github-release-version.herokuapp.com/github/sidoh/esp8266_thermometer/release.svg?style=flat)](https://github.com/sidoh/esp8266_thermometer/releases/latest) [![License][shield-license]][info-license] 2 | 3 | ESP8266-based thermometer. Pushes temperature data to a URL or MQTT at a configurable interval. Suitable for battery power. Works with multiple probes. 4 | 5 | ## Parts 6 | 7 | * ESP8266. I'm using a NodeMCUs. 8 | * DS18B20 temperature probe(s). Probably works with other Dallas Instruments temperature probes. 9 | * (optional) batteries and battery holder. I've had good luck with a Li-Ion 18650 cell. 10 | 11 | ## Circuit 12 | 13 | Not even worth drawing out. Data line from the DS18B20 is connected to GPIO 2 by default (D4 on Wemos D1), and has a 4.7 KΩ pullup resistor. You might want to user a lower resistor value if you have a long wire run or many sensors. 14 | 15 | ## Configuring 16 | 17 | #### WiFi 18 | 19 | If WiFi isn't configured, it'll start a setup WiFi network named something like Thermometer_XXXX. **The password is `fireitup`**. Connect to this network to configure WiFi. 20 | 21 | #### Other settings 22 | 23 | The other settings are stored as JSON in SPIFFS. When the chip is unconfigured, it starts a web server on port 80. Navigate here to edit the settings. 24 | 25 | 26 | 27 | #### Multiple sensors 28 | 29 | Sensors connected to the OneWire bus will be auto-detected. Data from all sensors will be pushed. You can configure aliases for detected device IDs in the UI or via the REST API. 30 | 31 | #### Operating mode 32 | 33 | There are two operating modes: Always On, and Deep Sleep. In Always On mode, the device will stay powered and connected to WiFi. The UI will stay running. This is good when connected to a persistent power source. Deep Sleep will push sensor readings to MQTT/HTTP and enter deep sleep. This is better when using a battery. 34 | 35 | **Breaking out of deep sleep loop** 36 | 37 | Each time the device wakes from deep sleep, it checks if it can connect to the "flag server" (configured in the JSON blob), and if the flag server sends the string **`update`**. If it does, it'll boot into settings mode. 38 | 39 | Example command: 40 | 41 | ``` 42 | $ echo -ne 'update' | nc -vvl 31415 43 | ``` 44 | 45 | #### OTA updates 46 | 47 | You can push firmware updates to `POST /firmware` when in settings mode. This can also be done through the UI. 48 | 49 | ## Integrations 50 | 51 | #### MQTT 52 | 53 | To push updates to MQTT, add an MQTT server and a topic prefix. You can optionally configure a username and password. Updates will be sent to the topic `/` for each detected sensor. `sensor_name` will be the device ID if an alias hasn't been added. 54 | 55 | #### HTTP 56 | 57 | To push updates to HTTP, configure a gateway server and a path for each sensor you want to push data for. Example: 58 | 59 | 60 | 61 | If you configure an HMAC secret, an HMAC of the path, body, and current timestamp will be included in the request. This allows you to verify the authenticity of the request. HMAC is computed for the concatenation of: 62 | 63 | * The path being requested on the gateway server 64 | * The body of the request 65 | * Current timestamp 66 | 67 | The signature and the timestamp are included respectively as the HTTP headers `X-Signature` and `X-Signature-Timestamp`. 68 | 69 | ## REST Routes 70 | 71 | The following routes are available when the settings server is active: 72 | 73 | * `GET /` - the settings index page 74 | * `GET /thermometers` - gets list of thermometers 75 | * `GET /thermometers/:thermometer` - `:thermometer` can either be address or alias 76 | * `GET /settings` - return settings as JSON 77 | * `PUT /settings` - patch settings. Body should be JSON 78 | * `GET /about` - bunch of environment info 79 | * `POST /update` 80 | 81 | [info-license]: https://github.com/sidoh/esp8266_thermometer/blob/master/LICENSE 82 | [shield-license]: https://img.shields.io/badge/license-MIT-blue.svg 83 | -------------------------------------------------------------------------------- /circuit.fzz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/esp8266_thermometer/46abe0b877ca561747b45bb54e8be8529c73db42/circuit.fzz -------------------------------------------------------------------------------- /lib/HTTP/ThermometerWebserver.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #if defined(ESP8266) 10 | #include 11 | #elif defined(ESP32) 12 | #include 13 | #endif 14 | 15 | static const char TEXT_HTML[] = "text/html"; 16 | static const char TEXT_PLAIN[] = "text/plain"; 17 | static const char APPLICATION_JSON[] = "application/json"; 18 | 19 | static const char CONTENT_TYPE_HEADER[] = "Content-Type"; 20 | 21 | using namespace std::placeholders; 22 | 23 | ThermometerWebserver::ThermometerWebserver(TempIface& sensors, Settings& settings) 24 | : authProvider(settings) 25 | , server(RichHttpServer(settings.webPort, authProvider)) 26 | , sensors(sensors) 27 | , settings(settings) 28 | , port(settings.webPort) 29 | { } 30 | 31 | ThermometerWebserver::~ThermometerWebserver() { 32 | server.reset(); 33 | } 34 | 35 | uint16_t ThermometerWebserver::getPort() const { 36 | return port; 37 | } 38 | 39 | void ThermometerWebserver::begin() { 40 | server 41 | .buildHandler("/settings") 42 | .on(HTTP_GET, std::bind(&ThermometerWebserver::handleListSettings, this, _1)) 43 | .on(HTTP_PUT, std::bind(&ThermometerWebserver::handleUpdateSettings, this, _1)); 44 | 45 | server 46 | .buildHandler("/about") 47 | .on(HTTP_GET, std::bind(&ThermometerWebserver::handleAbout, this, _1)); 48 | 49 | server 50 | .buildHandler("/firmware") 51 | .handleOTA(); 52 | 53 | server 54 | .buildHandler("/thermometers/:thermometer") 55 | .on(HTTP_GET, std::bind(&ThermometerWebserver::handleGetThermometer, this, _1)); 56 | server 57 | .buildHandler("/thermometers") 58 | .on(HTTP_GET, std::bind(&ThermometerWebserver::handleListThermometers, this, _1)); 59 | 60 | server 61 | .buildHandler("/commands") 62 | .on(HTTP_POST, std::bind(&ThermometerWebserver::handleCreateCommand, this, _1)); 63 | 64 | server 65 | .buildHandler("/") 66 | .on(HTTP_GET, std::bind(&ThermometerWebserver::serveProgmemStr, this, INDEX_PAGE_HTML, TEXT_HTML, _1)); 67 | server 68 | .buildHandler("/style.css") 69 | .on(HTTP_GET, std::bind(&ThermometerWebserver::serveProgmemStr, this, STYLESHEET, "text/css", _1)); 70 | server 71 | .buildHandler("/script.js") 72 | .on(HTTP_GET, std::bind(&ThermometerWebserver::serveProgmemStr, this, JAVASCRIPT, "application/javascript", _1)); 73 | 74 | server.clearBuilders(); 75 | server.begin(); 76 | } 77 | 78 | void ThermometerWebserver::handleCreateCommand(RequestContext& request) { 79 | JsonObject req = request.getJsonBody().as(); 80 | 81 | if (req.isNull()) { 82 | request.response.json["error"] = F("Invalid JSON - must be object"); 83 | request.response.setCode(400); 84 | return; 85 | } 86 | 87 | if (! req.containsKey("command")) { 88 | request.response.json["error"] = F("JSON did not contain `command' key"); 89 | request.response.setCode(400); 90 | return; 91 | } 92 | 93 | const String& command = req["command"]; 94 | if (command.equalsIgnoreCase("reboot")) { 95 | request.rawRequest->send_P(200, TEXT_PLAIN, PSTR("OK")); 96 | 97 | ESP.restart(); 98 | } else { 99 | request.response.json["error"] = F("Unhandled command"); 100 | request.response.setCode(400); 101 | } 102 | } 103 | 104 | void ThermometerWebserver::handleListThermometers(RequestContext& request) { 105 | JsonArray thermometers = request.response.json.to(); 106 | const std::map& sensorIds = sensors.thermometerIds(); 107 | 108 | for (std::map::const_iterator itr = sensorIds.begin(); itr != sensorIds.end(); ++itr) { 109 | JsonObject therm = thermometers.createNestedObject(); 110 | 111 | if (settings.deviceAliases.count(itr->first) > 0) { 112 | therm["name"] = settings.deviceAliases[itr->first]; 113 | } 114 | 115 | therm["temperature"] = sensors.lastSeenTemp(itr->first); 116 | therm["id"] = itr->first; 117 | } 118 | } 119 | 120 | void ThermometerWebserver::handleGetThermometer(RequestContext& request) { 121 | if (request.pathVariables.hasBinding("thermometer")) { 122 | const char* thermometer = request.pathVariables.get("thermometer"); 123 | uint8_t addr[8]; 124 | String name; 125 | 126 | // If the provided token is an ID we have an alias for 127 | if (settings.deviceAliases.count(thermometer) > 0) { 128 | name = settings.deviceAliases[thermometer]; 129 | hexStrToBytes(thermometer, strlen(thermometer), addr, 8); 130 | // Otherwise, if it's an alias, try to find it 131 | } else { 132 | bool found = false; 133 | 134 | for (std::map::iterator itr = settings.deviceAliases.begin(); itr != settings.deviceAliases.end(); ++itr) { 135 | if (itr->second == thermometer) { 136 | hexStrToBytes(itr->first.c_str(), itr->first.length(), addr, 8); 137 | name = thermometer; 138 | found = true; 139 | break; 140 | } 141 | } 142 | 143 | // Try to treat it as an address 144 | if (!found) { 145 | hexStrToBytes(thermometer, strlen(thermometer), addr, 8); 146 | } 147 | } 148 | 149 | char addrStr[50]; 150 | IntParsing::bytesToHexStr(addr, 8, addrStr, sizeof(addrStr)-1); 151 | 152 | if (sensors.hasSeenId(addrStr)) { 153 | JsonObject json = request.response.json.to(); 154 | 155 | float temp = sensors.lastSeenTemp(addrStr); 156 | 157 | json["id"] = addrStr; 158 | json["name"] = name; 159 | json["current_temperature"] = temp; 160 | } else { 161 | request.response.json["error"] = F("Could not find the provided thermometer"); 162 | request.response.setCode(404); 163 | } 164 | } else { 165 | request.response.json["error"] = PSTR("You must provide a thermometer name"); 166 | request.response.setCode(400); 167 | } 168 | } 169 | 170 | void ThermometerWebserver::serveProgmemStr(const char* pgmStr, const char* contentType, RequestContext& request) { 171 | request.rawRequest->send_P(200, contentType, pgmStr); 172 | } 173 | 174 | void ThermometerWebserver::handleAbout(RequestContext& request) { 175 | // Measure before allocating buffers 176 | uint32_t freeHeap = ESP.getFreeHeap(); 177 | 178 | JsonObject res = request.response.json.to(); 179 | 180 | res["version"] = QUOTE(ESP8266_THERMOMETER_VERSION); 181 | res["variant"] = QUOTE(FIRMWARE_VARIANT); 182 | res["voltage"] = analogRead(A0); 183 | res["signal_strength"] = WiFi.RSSI(); 184 | res["free_heap"] = freeHeap; 185 | res["sdk_version"] = ESP.getSdkVersion(); 186 | } 187 | 188 | void ThermometerWebserver::handleUpdateSettings(RequestContext& request) { 189 | JsonObject req = request.getJsonBody().as(); 190 | 191 | if (req.isNull()) { 192 | request.response.json["error"] = F("Invalid JSON"); 193 | request.response.setCode(400); 194 | return; 195 | } 196 | 197 | settings.patch(req); 198 | settings.save(); 199 | 200 | request.rawRequest->send(SPIFFS, SETTINGS_FILE, APPLICATION_JSON); 201 | } 202 | 203 | void ThermometerWebserver::handleListSettings(RequestContext& request) { 204 | request.rawRequest->send(SPIFFS, SETTINGS_FILE, APPLICATION_JSON); 205 | } -------------------------------------------------------------------------------- /lib/HTTP/ThermometerWebserver.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | #ifndef _WEB_SERVER_H 8 | #define _WEB_SERVER_H 9 | 10 | using RichHttpConfig = RichHttp::Generics::Configs::AsyncWebServer; 11 | using RequestContext = RichHttpConfig::RequestContextType; 12 | 13 | class ThermometerWebserver { 14 | public: 15 | ThermometerWebserver(TempIface& sensors, Settings& settings); 16 | ~ThermometerWebserver(); 17 | 18 | void begin(); 19 | uint16_t getPort() const; 20 | 21 | private: 22 | PassthroughAuthProvider authProvider; 23 | RichHttpServer server; 24 | TempIface& sensors; 25 | Settings& settings; 26 | uint16_t port; 27 | 28 | // Special routes 29 | void handleAbout(RequestContext& request); 30 | void handleOtaUpdate(RequestContext& request); 31 | void handleOtaSuccess(RequestContext& request); 32 | void handleCreateCommand(RequestContext& request); 33 | 34 | void handleUpdateSettings(RequestContext& request); 35 | void handleListSettings(RequestContext& request); 36 | 37 | void handleListThermometers(RequestContext& request); 38 | void handleGetThermometer(RequestContext& request); 39 | 40 | void serveProgmemStr(const char* pgmStr, const char* contentType, RequestContext& request); 41 | }; 42 | 43 | #endif 44 | -------------------------------------------------------------------------------- /lib/Helpers/IntParsing.h: -------------------------------------------------------------------------------- 1 | #ifndef _INTPARSING_H 2 | #define _INTPARSING_H 3 | 4 | #include 5 | 6 | template 7 | const T strToHex(const char* s, size_t length) { 8 | T value = 0; 9 | T base = 1; 10 | 11 | for (int i = length-1; i >= 0; i--) { 12 | const char c = s[i]; 13 | 14 | if (c >= '0' && c <= '9') { 15 | value += ((c - '0') * base); 16 | } else if (c >= 'a' && c <= 'f') { 17 | value += ((c - 'a' + 10) * base); 18 | } else if (c >= 'A' && c <= 'F') { 19 | value += ((c - 'A' + 10) * base); 20 | } else { 21 | break; 22 | } 23 | 24 | base <<= 4; 25 | } 26 | 27 | return value; 28 | } 29 | 30 | template 31 | const T strToHex(const String& s) { 32 | return strToHex(s.c_str(), s.length()); 33 | } 34 | 35 | template 36 | const T parseInt(const String& s) { 37 | if (s.startsWith("0x")) { 38 | return strToHex(s.substring(2)); 39 | } else { 40 | return s.toInt(); 41 | } 42 | } 43 | 44 | template 45 | void hexStrToBytes(const char* s, const size_t sLen, T* buffer, size_t maxLen) { 46 | int idx = 0; 47 | 48 | for (int i = 0; i < sLen && idx < maxLen; ) { 49 | buffer[idx++] = strToHex(s+i, 2); 50 | i+= 2; 51 | 52 | while (i < (sLen - 1) && s[i] == ' ') { 53 | i++; 54 | } 55 | } 56 | } 57 | 58 | class IntParsing { 59 | public: 60 | static void bytesToHexStr(const uint8_t* bytes, const size_t len, char* buffer, size_t maxLen) { 61 | char* p = buffer; 62 | 63 | for (size_t i = 0; i < len && (p - buffer) < (maxLen - 2); i++) { 64 | p += sprintf(p, "%02X", bytes[i]); 65 | } 66 | } 67 | }; 68 | 69 | #endif 70 | -------------------------------------------------------------------------------- /lib/MQTT/MqttClient.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | MqttClient::MqttClient(Settings& settings) 6 | : settings(settings), 7 | lastConnectAttempt(0) 8 | { 9 | String strDomain = settings.mqttServer(); 10 | this->domain = new char[strDomain.length() + 1]; 11 | strcpy(this->domain, strDomain.c_str()); 12 | 13 | this->mqttClient = new PubSubClient(tcpClient); 14 | } 15 | 16 | MqttClient::~MqttClient() { 17 | mqttClient->disconnect(); 18 | delete this->domain; 19 | } 20 | 21 | void MqttClient::begin() { 22 | #ifdef MQTT_DEBUG 23 | printf_P( 24 | PSTR("MqttClient - Connecting to: %s\nparsed:%s:%u\n"), 25 | settings._mqttServer.c_str(), 26 | settings.mqttServer().c_str(), 27 | settings.mqttPort() 28 | ); 29 | #endif 30 | 31 | mqttClient->setServer(this->domain, settings.mqttPort()); 32 | 33 | reconnect(); 34 | } 35 | 36 | bool MqttClient::connect() { 37 | char nameBuffer[30]; 38 | sprintf_P(nameBuffer, PSTR("esp8266-thermometer-%u"), ESP.getChipId()); 39 | 40 | #ifdef MQTT_DEBUG 41 | Serial.println(F("MqttClient - connecting")); 42 | #endif 43 | 44 | if (settings.mqttUsername.length() > 0) { 45 | return mqttClient->connect( 46 | nameBuffer, 47 | settings.mqttUsername.c_str(), 48 | settings.mqttPassword.c_str() 49 | ); 50 | } else { 51 | return mqttClient->connect(nameBuffer); 52 | } 53 | } 54 | 55 | void MqttClient::reconnect() { 56 | if (lastConnectAttempt > 0 && (millis() - lastConnectAttempt) < MQTT_CONNECTION_ATTEMPT_FREQUENCY) { 57 | return; 58 | } 59 | 60 | if (! mqttClient->connected()) { 61 | if (connect()) { 62 | subscribe(); 63 | 64 | #ifdef MQTT_DEBUG 65 | Serial.println(F("MqttClient - Successfully connected to MQTT server")); 66 | #endif 67 | } else { 68 | Serial.println(F("ERROR: Failed to connect to MQTT server")); 69 | } 70 | } 71 | 72 | lastConnectAttempt = millis(); 73 | } 74 | 75 | void MqttClient::handleClient() { 76 | reconnect(); 77 | mqttClient->loop(); 78 | } 79 | 80 | void MqttClient::sendUpdate(const String& deviceName, const char* update) { 81 | String topic = settings.mqttTopic; 82 | topic += "/"; 83 | topic += deviceName; 84 | 85 | publish(topic, update, true); 86 | } 87 | 88 | void MqttClient::subscribe() { 89 | // This is necessary with pubsubclient because it assumes that a subscription is necessary in order 90 | // to maintain a connection. 91 | const char* phonyTopic = "esp8266-thermometer/empty-topic"; 92 | 93 | #ifdef MQTT_DEBUG 94 | printf_P(PSTR("MqttClient - subscribing phony topic: %s\n"), phonyTopic); 95 | #endif 96 | 97 | mqttClient->subscribe(phonyTopic); 98 | } 99 | 100 | void MqttClient::publish( 101 | const String& topic, 102 | const char* message, 103 | const bool retain 104 | ) { 105 | if (topic.length() == 0) { 106 | return; 107 | } 108 | 109 | #ifdef MQTT_DEBUG 110 | printf("MqttClient - publishing update to %s\n", topic.c_str()); 111 | #endif 112 | 113 | mqttClient->publish(topic.c_str(), message, retain); 114 | } -------------------------------------------------------------------------------- /lib/MQTT/MqttClient.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #ifndef MQTT_CONNECTION_ATTEMPT_FREQUENCY 6 | #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000 7 | #endif 8 | 9 | #ifndef _MQTT_CLIENT_H 10 | #define _MQTT_CLIENT_H 11 | 12 | class MqttClient { 13 | public: 14 | MqttClient(Settings& settings); 15 | ~MqttClient(); 16 | 17 | void begin(); 18 | void handleClient(); 19 | void reconnect(); 20 | void sendUpdate(const String& deviceName, const char* update); 21 | 22 | private: 23 | WiFiClient tcpClient; 24 | PubSubClient* mqttClient; 25 | Settings& settings; 26 | char* domain; 27 | unsigned long lastConnectAttempt; 28 | 29 | bool connect(); 30 | void subscribe(); 31 | void publishCallback(char* topic, byte* payload, int length); 32 | void publish( 33 | const String& topic, 34 | const char* update, 35 | const bool retain = false 36 | ); 37 | }; 38 | 39 | #endif -------------------------------------------------------------------------------- /lib/Settings/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | 7 | #define PORT_POSITION(s) ( s.indexOf(':') ) 8 | 9 | static const char* OP_MODE_NAMES[2] = { 10 | "deep_sleep", 11 | "always_on" 12 | }; 13 | 14 | OperatingMode opModeFromString(const String& s) { 15 | for (size_t i = 0; i < sizeof(OP_MODE_NAMES); i++) { 16 | if (s == OP_MODE_NAMES[i]) { 17 | return static_cast(i); 18 | } 19 | } 20 | return OperatingMode::DEEP_SLEEP; 21 | } 22 | 23 | template 24 | bool isDefined(const T& setting) { 25 | return setting != NULL && setting.length() > 0; 26 | } 27 | 28 | String Settings::mqttServer() { 29 | int pos = PORT_POSITION(_mqttServer); 30 | 31 | if (pos == -1) { 32 | return _mqttServer; 33 | } else { 34 | return _mqttServer.substring(0, pos); 35 | } 36 | } 37 | 38 | uint16_t Settings::mqttPort() { 39 | int pos = PORT_POSITION(_mqttServer); 40 | 41 | if (pos == -1) { 42 | return DEFAULT_MQTT_PORT; 43 | } else { 44 | return atoi(_mqttServer.c_str() + pos + 1); 45 | } 46 | } 47 | 48 | bool Settings::requiredSettingsDefined() { 49 | return isDefined(flagServer) && flagServerPort > 0; 50 | } 51 | 52 | bool Settings::isAuthenticationEnabled() const { 53 | return adminUsername.length() > 0 && adminPassword.length() > 0; 54 | } 55 | 56 | const String& Settings::getUsername() const { 57 | return adminUsername; 58 | } 59 | 60 | const String& Settings::getPassword() const { 61 | return adminPassword; 62 | } 63 | 64 | String Settings::deviceName(uint8_t* addr, bool resolveAlias) { 65 | char deviceIdHex[50]; 66 | IntParsing::bytesToHexStr(addr, 8, deviceIdHex, sizeof(deviceIdHex) - 1); 67 | 68 | if (resolveAlias && deviceAliases.count(deviceIdHex) > 0) { 69 | return deviceAliases[deviceIdHex]; 70 | } else { 71 | return deviceIdHex; 72 | } 73 | } 74 | 75 | void Settings::deserialize(Settings& settings, String json) { 76 | DynamicJsonDocument jsonBuffer(2048); 77 | deserializeJson(jsonBuffer, json); 78 | JsonObject parsedSettings = jsonBuffer.as(); 79 | 80 | if (parsedSettings.isNull()) { 81 | Serial.println(F("ERROR: could not parse settings file on flash")); 82 | } 83 | 84 | settings.patch(parsedSettings); 85 | } 86 | 87 | void Settings::patch(JsonObject json) { 88 | setIfPresent(json, "mqtt.server", _mqttServer); 89 | setIfPresent(json, "mqtt.topic_prefix", mqttTopic); 90 | setIfPresent(json, "mqtt.username", mqttUsername); 91 | setIfPresent(json, "mqtt.password", mqttPassword); 92 | 93 | setIfPresent(json, "http.gateway_server", gatewayServer); 94 | setIfPresent(json, "http.hmac_secret", hmacSecret); 95 | 96 | setIfPresent(json, "admin.web_ui_port", webPort); 97 | setIfPresent(json, "admin.flag_server", flagServer); 98 | setIfPresent(json, "admin.flag_server_port", flagServerPort); 99 | setIfPresent(json, "admin.username", adminUsername); 100 | setIfPresent(json, "admin.password", adminPassword); 101 | setIfPresent(json, "thermometers.update_interval", updateInterval); 102 | setIfPresent(json, "thermometers.poll_interval", sensorPollInterval); 103 | setIfPresent(json, "thermometers.sensor_bus_pin", sensorBusPin); 104 | 105 | if (json.containsKey("admin.operating_mode")) { 106 | opMode = opModeFromString(json["admin.operating_mode"]); 107 | } 108 | 109 | if (json.containsKey("thermometers.aliases")) { 110 | JsonObject aliases = json["thermometers.aliases"]; 111 | deviceAliases.clear(); 112 | 113 | for (JsonObject::iterator itr = aliases.begin(); itr != aliases.end(); ++itr) { 114 | const char* value = itr->value().as(); 115 | 116 | if (strlen(value) > 0) { 117 | deviceAliases[itr->key().c_str()] = value; 118 | } else { 119 | deviceAliases.erase(itr->key().c_str()); 120 | } 121 | } 122 | } 123 | 124 | if (json.containsKey("http.sensor_paths")) { 125 | JsonObject sensorPaths = json["http.sensor_paths"].as(); 126 | this->sensorPaths.clear(); 127 | 128 | for (JsonObject::iterator itr = sensorPaths.begin(); itr != sensorPaths.end(); ++itr) { 129 | const char* value = itr->value().as(); 130 | 131 | if (strlen(value) > 0) { 132 | this->sensorPaths[itr->key().c_str()] = value; 133 | } else { 134 | this->sensorPaths.erase(itr->key().c_str()); 135 | } 136 | } 137 | } 138 | } 139 | 140 | void Settings::load(Settings& settings) { 141 | if (SPIFFS.exists(SETTINGS_FILE)) { 142 | File f = SPIFFS.open(SETTINGS_FILE, "r"); 143 | String settingsContents = f.readStringUntil(SETTINGS_TERMINATOR); 144 | f.close(); 145 | 146 | deserialize(settings, settingsContents); 147 | } else { 148 | settings.save(); 149 | } 150 | } 151 | 152 | void Settings::save() { 153 | File f = SPIFFS.open(SETTINGS_FILE, "w"); 154 | 155 | if (!f) { 156 | Serial.println("Opening settings file failed"); 157 | } else { 158 | serialize(f); 159 | f.close(); 160 | } 161 | } 162 | 163 | void Settings::serialize(Stream& stream, const bool prettyPrint) { 164 | DynamicJsonDocument jsonBuffer(2048); 165 | JsonObject root = jsonBuffer.to(); 166 | 167 | root["mqtt.server"] = this->_mqttServer; 168 | root["mqtt.topic_prefix"] = this->mqttTopic; 169 | root["mqtt.username"] = this->mqttUsername; 170 | root["mqtt.password"] = this->mqttPassword; 171 | 172 | root["http.gateway_server"] = this->gatewayServer; 173 | root["http.hmac_secret"] = this->hmacSecret; 174 | 175 | root["admin.web_ui_port"] = this->webPort; 176 | root["admin.flag_server"] = this->flagServer; 177 | root["admin.flag_server_port"] = this->flagServerPort; 178 | root["admin.username"] = this->adminUsername; 179 | root["admin.password"] = this->adminPassword; 180 | root["admin.operating_mode"] = OP_MODE_NAMES[static_cast(this->opMode)]; 181 | root["thermometers.sensor_bus_pin"] = this->sensorBusPin; 182 | root["thermometers.update_interval"] = this->updateInterval; 183 | root["thermometers.poll_interval"] = this->sensorPollInterval; 184 | 185 | JsonObject aliases = root.createNestedObject("thermometers.aliases"); 186 | for (std::map::iterator itr = this->deviceAliases.begin(); itr != this->deviceAliases.end(); ++itr) { 187 | aliases[itr->first] = itr->second; 188 | } 189 | 190 | JsonObject sensorPaths = root.createNestedObject("http.sensor_paths"); 191 | for (std::map::iterator itr = this->sensorPaths.begin(); itr != this->sensorPaths.end(); ++itr) { 192 | sensorPaths[itr->first] = itr->second; 193 | } 194 | 195 | if (prettyPrint) { 196 | serializeJsonPretty(root, stream); 197 | } else { 198 | serializeJson(root, stream); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /lib/Settings/Settings.h: -------------------------------------------------------------------------------- 1 | #ifndef _SETTINGS_H_INCLUDED 2 | #define _SETTINGS_H_INCLUDED 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #define SETTINGS_FILE "/config.json" 10 | #define SETTINGS_TERMINATOR '\0' 11 | 12 | #define XQUOTE(x) #x 13 | #define QUOTE(x) XQUOTE(x) 14 | 15 | #ifndef FIRMWARE_VARIANT 16 | #define FIRMWARE_VARIANT unknown 17 | #endif 18 | 19 | #ifndef ESP8266_THERMOMETER_VERSION 20 | #define ESP8266_THERMOMETER_VERSION unknown 21 | #endif 22 | 23 | #define DEFAULT_MQTT_PORT 1883 24 | 25 | enum class OperatingMode { 26 | DEEP_SLEEP = 0, 27 | ALWAYS_ON = 1 28 | }; 29 | 30 | class Settings { 31 | public: 32 | Settings() 33 | : flagServerPort(31415) 34 | , updateInterval(600) 35 | , sensorPollInterval(5) 36 | , webPort(80) 37 | , opMode(OperatingMode::DEEP_SLEEP) 38 | , sensorBusPin(2) 39 | { } 40 | 41 | static void deserialize(Settings& settings, String json); 42 | static void load(Settings& settings); 43 | 44 | void save(); 45 | String toJson(const bool prettyPrint = true); 46 | void serialize(Stream& stream, const bool prettyPrint = false); 47 | void patch(JsonObject json); 48 | 49 | bool requiredSettingsDefined(); 50 | 51 | bool isAuthenticationEnabled() const; 52 | const String& getUsername() const; 53 | const String& getPassword() const; 54 | 55 | String mqttServer(); 56 | uint16_t mqttPort(); 57 | 58 | String deviceName(uint8_t* addr, bool resolveDeviceName = true); 59 | 60 | String adminUsername; 61 | String adminPassword; 62 | uint16_t webPort; 63 | 64 | String gatewayServer; 65 | String hmacSecret; 66 | 67 | String flagServer; 68 | uint16 flagServerPort; 69 | 70 | unsigned long updateInterval; 71 | time_t sensorPollInterval; 72 | OperatingMode opMode; 73 | 74 | String _mqttServer; 75 | String mqttTopic; 76 | String mqttUsername; 77 | String mqttPassword; 78 | 79 | uint8_t sensorBusPin; 80 | 81 | std::map deviceAliases; 82 | std::map sensorPaths; 83 | 84 | template 85 | void setIfPresent(JsonObject obj, const char* key, T& var) { 86 | if (obj.containsKey(key)) { 87 | JsonVariant val = obj[key]; 88 | var = val.as(); 89 | } 90 | } 91 | }; 92 | 93 | #endif 94 | -------------------------------------------------------------------------------- /lib/Sha/HmacHelpers.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | String bin2hex(const uint8_t* bin, const int length) { 6 | String hex = ""; 7 | 8 | for (int i = 0; i < length; i++) { 9 | if (bin[i] < 16) { 10 | hex += "0"; 11 | } 12 | hex += String(bin[i], HEX); 13 | } 14 | 15 | return hex; 16 | } 17 | 18 | String hmacDigest(String key, String message) { 19 | Sha1.initHmac((uint8_t*)key.c_str(), key.length()); 20 | Sha1.print(message); 21 | return bin2hex(Sha1.resultHmac(), HASH_LENGTH); 22 | } 23 | 24 | String requestSignature(String key, String path, String body, time_t timestamp) { 25 | String payload = path + body + String(timestamp); 26 | return hmacDigest(key, payload); 27 | } -------------------------------------------------------------------------------- /lib/Sha/HmacHelpers.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | String hmacDigest(String key, String message); 5 | String requestSignature(String key, String path, String body, time_t timestamp); -------------------------------------------------------------------------------- /lib/Sha/sha1.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "sha1.h" 4 | 5 | #define SHA1_K0 0x5a827999 6 | #define SHA1_K20 0x6ed9eba1 7 | #define SHA1_K40 0x8f1bbcdc 8 | #define SHA1_K60 0xca62c1d6 9 | 10 | uint8_t sha1InitState[] PROGMEM = { 11 | 0x01,0x23,0x45,0x67, // H0 12 | 0x89,0xab,0xcd,0xef, // H1 13 | 0xfe,0xdc,0xba,0x98, // H2 14 | 0x76,0x54,0x32,0x10, // H3 15 | 0xf0,0xe1,0xd2,0xc3 // H4 16 | }; 17 | 18 | void Sha1Class::init(void) { 19 | memcpy_P(state.b,sha1InitState,HASH_LENGTH); 20 | byteCount = 0; 21 | bufferOffset = 0; 22 | } 23 | 24 | uint32_t Sha1Class::rol32(uint32_t number, uint8_t bits) { 25 | return ((number << bits) | (number >> (32-bits))); 26 | } 27 | 28 | void Sha1Class::hashBlock() { 29 | uint8_t i; 30 | uint32_t a,b,c,d,e,t; 31 | 32 | a=state.w[0]; 33 | b=state.w[1]; 34 | c=state.w[2]; 35 | d=state.w[3]; 36 | e=state.w[4]; 37 | for (i=0; i<80; i++) { 38 | if (i>=16) { 39 | t = buffer.w[(i+13)&15] ^ buffer.w[(i+8)&15] ^ buffer.w[(i+2)&15] ^ buffer.w[i&15]; 40 | buffer.w[i&15] = rol32(t,1); 41 | } 42 | if (i<20) { 43 | t = (d ^ (b & (c ^ d))) + SHA1_K0; 44 | } else if (i<40) { 45 | t = (b ^ c ^ d) + SHA1_K20; 46 | } else if (i<60) { 47 | t = ((b & c) | (d & (b | c))) + SHA1_K40; 48 | } else { 49 | t = (b ^ c ^ d) + SHA1_K60; 50 | } 51 | t+=rol32(a,5) + e + buffer.w[i&15]; 52 | e=d; 53 | d=c; 54 | c=rol32(b,30); 55 | b=a; 56 | a=t; 57 | } 58 | state.w[0] += a; 59 | state.w[1] += b; 60 | state.w[2] += c; 61 | state.w[3] += d; 62 | state.w[4] += e; 63 | } 64 | 65 | void Sha1Class::addUncounted(uint8_t data) { 66 | buffer.b[bufferOffset ^ 3] = data; 67 | bufferOffset++; 68 | if (bufferOffset == BLOCK_LENGTH) { 69 | hashBlock(); 70 | bufferOffset = 0; 71 | } 72 | } 73 | 74 | size_t Sha1Class::write(uint8_t data) { 75 | ++byteCount; 76 | addUncounted(data); 77 | return 0; 78 | } 79 | 80 | void Sha1Class::pad() { 81 | // Implement SHA-1 padding (fips180-2 §5.1.1) 82 | 83 | // Pad with 0x80 followed by 0x00 until the end of the block 84 | addUncounted(0x80); 85 | while (bufferOffset != 56) addUncounted(0x00); 86 | 87 | // Append length in the last 8 bytes 88 | addUncounted(0); // We're only using 32 bit lengths 89 | addUncounted(0); // But SHA-1 supports 64 bit lengths 90 | addUncounted(0); // So zero pad the top bits 91 | addUncounted(byteCount >> 29); // Shifting to multiply by 8 92 | addUncounted(byteCount >> 21); // as SHA-1 supports bitstreams as well as 93 | addUncounted(byteCount >> 13); // byte. 94 | addUncounted(byteCount >> 5); 95 | addUncounted(byteCount << 3); 96 | } 97 | 98 | 99 | uint8_t* Sha1Class::result(void) { 100 | // Pad to complete the last block 101 | pad(); 102 | 103 | // Swap byte order back 104 | for (int i=0; i<5; i++) { 105 | uint32_t a,b; 106 | a=state.w[i]; 107 | b=a<<24; 108 | b|=(a<<8) & 0x00ff0000; 109 | b|=(a>>8) & 0x0000ff00; 110 | b|=a>>24; 111 | state.w[i]=b; 112 | } 113 | 114 | // Return pointer to hash (20 characters) 115 | return state.b; 116 | } 117 | 118 | #define HMAC_IPAD 0x36 119 | #define HMAC_OPAD 0x5c 120 | 121 | void Sha1Class::initHmac(const uint8_t* key, int keyLength) { 122 | uint8_t i; 123 | memset(keyBuffer,0,BLOCK_LENGTH); 124 | if (keyLength > BLOCK_LENGTH) { 125 | // Hash long keys 126 | init(); 127 | for (;keyLength--;) write(*key++); 128 | memcpy(keyBuffer,result(),HASH_LENGTH); 129 | } else { 130 | // Block length keys are used as is 131 | memcpy(keyBuffer,key,keyLength); 132 | } 133 | // Start inner hash 134 | init(); 135 | for (i=0; i 5 | #include 6 | 7 | #define HASH_LENGTH 20 8 | #define BLOCK_LENGTH 64 9 | 10 | union _buffer { 11 | uint8_t b[BLOCK_LENGTH]; 12 | uint32_t w[BLOCK_LENGTH/4]; 13 | }; 14 | union _state { 15 | uint8_t b[HASH_LENGTH]; 16 | uint32_t w[HASH_LENGTH/4]; 17 | }; 18 | 19 | class Sha1Class : public Print 20 | { 21 | public: 22 | void init(void); 23 | void initHmac(const uint8_t* secret, int secretLength); 24 | uint8_t* result(void); 25 | uint8_t* resultHmac(void); 26 | virtual size_t write(uint8_t); 27 | using Print::write; 28 | private: 29 | void pad(); 30 | void addUncounted(uint8_t data); 31 | void hashBlock(); 32 | uint32_t rol32(uint32_t number, uint8_t bits); 33 | _buffer buffer; 34 | uint8_t bufferOffset; 35 | _state state; 36 | uint32_t byteCount; 37 | uint8_t keyBuffer[BLOCK_LENGTH]; 38 | uint8_t innerHash[HASH_LENGTH]; 39 | 40 | }; 41 | extern Sha1Class Sha1; 42 | 43 | #endif 44 | -------------------------------------------------------------------------------- /lib/TempIface/TempIface.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | TempIface::TempIface(DallasTemperature*& sensors, Settings& settings) 5 | : sensors(sensors), 6 | settings(settings), 7 | lastUpdatedAt(0) 8 | { } 9 | 10 | TempIface::~TempIface() { } 11 | 12 | void TempIface::begin() { 13 | 14 | char strAddr[50]; 15 | 16 | Serial.printf_P(PSTR("[Thermometer Scan] Detected %d devices\n"), sensors->getDeviceCount()); 17 | 18 | for (size_t i = 0; i < sensors->getDeviceCount(); ++i) { 19 | uint8_t* addr = new uint8_t[8]; 20 | sensors->getAddress(addr, i); 21 | IntParsing::bytesToHexStr(addr, 8, strAddr, sizeof(strAddr)-1); 22 | 23 | Serial.printf_P(PSTR("[Thermometer Scan] ... found thermometer with address: %s\n"), strAddr); 24 | 25 | seenIds[strAddr] = addr; 26 | } 27 | 28 | } 29 | 30 | void TempIface::loop() { 31 | 32 | time_t n = now(); 33 | 34 | if (n > (lastUpdatedAt + settings.sensorPollInterval)) { 35 | for (std::map::iterator itr = seenIds.begin(); itr != seenIds.end(); ++itr) { 36 | sensors->requestTemperaturesByAddress(itr->second); 37 | lastTemps[itr->first] = sensors->getTempF(itr->second); 38 | } 39 | lastUpdatedAt = n; 40 | } 41 | 42 | } 43 | 44 | const std::map& TempIface::thermometerIds() { 45 | 46 | return seenIds; 47 | 48 | } 49 | 50 | const float TempIface::lastSeenTemp(const String& id) { 51 | 52 | if (hasSeenId(id)) { 53 | return lastTemps[id]; 54 | } else { 55 | return -187; 56 | } 57 | 58 | } 59 | 60 | const bool TempIface::hasSeenId(const String& id) { 61 | 62 | return lastTemps.count(id) > 0; 63 | 64 | } -------------------------------------------------------------------------------- /lib/TempIface/TempIface.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #ifndef _TEMP_IFACE_H 6 | #define _TEMP_IFACE_H 7 | 8 | class TempIface { 9 | public: 10 | 11 | TempIface(DallasTemperature*& sensors, Settings& settings); 12 | ~TempIface(); 13 | 14 | void begin(); 15 | void loop(); 16 | const std::map& thermometerIds(); 17 | const float lastSeenTemp(const String& id); 18 | const bool hasSeenId(const String& id); 19 | 20 | private: 21 | 22 | std::map seenIds; 23 | std::map lastTemps; 24 | time_t lastUpdatedAt; 25 | 26 | DallasTemperature*& sensors; 27 | Settings& settings; 28 | 29 | }; 30 | 31 | #endif // _TEMP_IFACE_H -------------------------------------------------------------------------------- /lib/TokenParsing/TokenIterator.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | TokenIterator::TokenIterator(char* data, size_t length, const char sep) 4 | : data(data), 5 | current(data), 6 | length(length), 7 | sep(sep), 8 | i(0) 9 | { 10 | for (size_t i = 0; i < length; i++) { 11 | if (data[i] == sep) { 12 | data[i] = 0; 13 | } 14 | } 15 | } 16 | 17 | const char* TokenIterator::nextToken() { 18 | if (i >= length) { 19 | return NULL; 20 | } 21 | 22 | char* token = current; 23 | char* nextToken = current; 24 | 25 | for (; i < length && *nextToken != 0; i++, nextToken++); 26 | 27 | if (i == length) { 28 | nextToken = NULL; 29 | } else { 30 | i = (nextToken - data); 31 | 32 | if (i < length) { 33 | nextToken++; 34 | } else { 35 | nextToken = NULL; 36 | } 37 | } 38 | 39 | current = nextToken; 40 | 41 | return token; 42 | } 43 | 44 | void TokenIterator::reset() { 45 | current = data; 46 | i = 0; 47 | } 48 | 49 | bool TokenIterator::hasNext() { 50 | return i < length; 51 | } 52 | -------------------------------------------------------------------------------- /lib/TokenParsing/TokenIterator.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _TOKEN_ITERATOR_H 4 | #define _TOKEN_ITERATOR_H 5 | 6 | class TokenIterator { 7 | public: 8 | TokenIterator(char* data, size_t length, char sep = ','); 9 | 10 | bool hasNext(); 11 | const char* nextToken(); 12 | void reset(); 13 | 14 | private: 15 | char* data; 16 | char* current; 17 | size_t length; 18 | char sep; 19 | int i; 20 | }; 21 | #endif 22 | -------------------------------------------------------------------------------- /lib/TokenParsing/UrlTokenBindings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | UrlTokenBindings::UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens) 4 | : patternTokens(patternTokens), 5 | requestTokens(requestTokens) 6 | { } 7 | 8 | bool UrlTokenBindings::hasBinding(const char* searchToken) const { 9 | patternTokens.reset(); 10 | while (patternTokens.hasNext()) { 11 | const char* token = patternTokens.nextToken(); 12 | 13 | if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { 14 | return true; 15 | } 16 | } 17 | 18 | return false; 19 | } 20 | 21 | const char* UrlTokenBindings::get(const char* searchToken) const { 22 | patternTokens.reset(); 23 | requestTokens.reset(); 24 | 25 | while (patternTokens.hasNext() && requestTokens.hasNext()) { 26 | const char* token = patternTokens.nextToken(); 27 | const char* binding = requestTokens.nextToken(); 28 | 29 | if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { 30 | return binding; 31 | } 32 | } 33 | 34 | return NULL; 35 | } 36 | -------------------------------------------------------------------------------- /lib/TokenParsing/UrlTokenBindings.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _URL_TOKEN_BINDINGS_H 4 | #define _URL_TOKEN_BINDINGS_H 5 | 6 | class UrlTokenBindings { 7 | public: 8 | UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens); 9 | 10 | bool hasBinding(const char* key) const; 11 | const char* get(const char* key) const; 12 | 13 | private: 14 | TokenIterator& patternTokens; 15 | TokenIterator& requestTokens; 16 | }; 17 | 18 | #endif 19 | -------------------------------------------------------------------------------- /lib/WebStrings/IndexPage.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _INDEX_PAGE_H 5 | #define _INDEX_PAGE_H 6 | 7 | const char INDEX_PAGE_HTML[] PROGMEM = R""""( 8 | 9 | 10 | 11 | 12 | 13 | 14 | Thermometer 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 41 | 42 |
43 | 59 | 60 |
61 |
62 |
63 | 66 | 67 |
68 |
69 |
70 |
71 | 72 | 73 |
74 |
75 |
76 | 77 |
78 |
79 |
80 |

Device Aliases

81 |
82 | 83 |
84 | 85 | 86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | 94 |
95 |
96 |
97 | 100 |
101 |
102 | 103 |
104 |
105 |
106 |
107 |
108 |
109 | 110 |
111 |
112 |
113 | 116 |
117 |
118 | 119 |
120 |
121 |
122 |

Firmware Upgrade

123 | 124 |
125 |

126 | Make sure the binary you're uploading was compiled for your board! 127 | Firmware with incompatible settings could prevent boots. If this happens, reflash the board with USB. 128 |

129 |
130 | 131 |
132 |
133 | 134 |
135 | 136 | 137 |
138 |
139 |
140 | 141 |
142 |
143 |

Admin Actions

144 | 145 |
146 | 147 | 148 |
149 |
150 |
151 |
152 |
153 |
154 | 155 | 156 | )""""; 157 | 158 | #endif -------------------------------------------------------------------------------- /lib/WebStrings/Javascript.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _JAVASCRIPT_N 5 | #define _JAVASCRIPT_N 6 | 7 | const char JAVASCRIPT[] PROGMEM = R""""( 8 | (function($) { 9 | 10 | var SETTING_KEYS = [ 11 | "mqtt.server", 12 | "mqtt.topic_prefix", 13 | "mqtt.username", 14 | "mqtt.password", 15 | 16 | "http.gateway_server", 17 | "http.hmac_secret", 18 | 19 | "admin.flag_server", 20 | "admin.flag_server_port", 21 | "admin.username", 22 | "admin.password", 23 | "admin.operating_mode", 24 | "admin.web_ui_port", 25 | 26 | "thermometers.update_interval", 27 | "thermometers.poll_interval", 28 | "thermometers.sensor_bus_pin" 29 | ]; 30 | 31 | var RADIO_FIELDS = { 32 | "admin.operating_mode": ["always_on", "deep_sleep"] 33 | }; 34 | 35 | var PASSWORD_FIELDS = { 36 | "admin.password": 1, 37 | "mqtt.password": 1 38 | }; 39 | 40 | var currentSettings = {}; 41 | 42 | var titleize = function(s) { 43 | var words = s.split(/[ _]+/); 44 | var r = []; 45 | 46 | words.forEach(function(word) { 47 | r.push(word.substr(0, 1).toUpperCase() + word.substr(1)); 48 | }); 49 | 50 | return r.join(' '); 51 | }; 52 | 53 | var serializeForm = function(f) { 54 | return $(f) 55 | .serializeArray() 56 | .reduce( 57 | function(a, x) { 58 | // Submit abc[def]=x, abc[xyz]=1 as {"abc":{"def":x,"xyz":1}} 59 | var hashMatch = x.name.match(/([^\[]+)\[([^\]]+)\]/); 60 | if (hashMatch) { 61 | var key = hashMatch[1], subKey = hashMatch[2]; 62 | 63 | if (!a[key]) { 64 | a[key] = {}; 65 | } 66 | 67 | a[key][subKey] = x.value; 68 | } else if (!a[x.name]) { 69 | a[x.name] = x.value; 70 | // submit abc[]=1, abc[]=2 as {"abc[]":[1,2]} 71 | } else { 72 | a[x.name] = [a[x.name]]; 73 | a[x.name].push(x.value); 74 | } 75 | return a; 76 | }, 77 | {} 78 | ); 79 | } 80 | 81 | var renderTextField = function(setting) { 82 | var type = PASSWORD_FIELDS[setting.key] ? 'password' : 'text'; 83 | 84 | var elmt = ''; 85 | elmt += ''; 86 | return elmt; 87 | }; 88 | 89 | var renderRadioField = function(setting) { 90 | var elmt = ''; 91 | elmt += '
'; 92 | 93 | RADIO_FIELDS[setting.key].forEach(function(option) { 94 | elmt += ''; 98 | }); 99 | 100 | elmt += '
'; 101 | 102 | return elmt; 103 | }; 104 | 105 | var showError = function(e) { 106 | console.log(e); 107 | var elmt = '
Encountered an error
'; 108 | $('body').prepend(elmt); 109 | }; 110 | 111 | var applyThermometers = function(data) { 112 | if (data.length == 0) { 113 | $('#aliases-form fieldset').append('No thermometers detected. Try checking connections and restarting'); 114 | } else { 115 | var insertElmt = $('.category-title[data-category="http"] + fieldset > input:last'); 116 | var pathsContainer = $('

Paths

'); 117 | pathsContainer.insertAfter(insertElmt); 118 | 119 | var temperatureContainer = $('
'); 120 | $('#current-temperatures').append(temperatureContainer); 121 | 122 | data.forEach(function(thermometer) { 123 | // Render alias field 124 | var elmt = $(renderTextField({name: thermometer.id, title: thermometer.id, key: thermometer.id})); 125 | $(elmt).val(thermometer.name); 126 | $('#aliases-form fieldset').append(elmt); 127 | 128 | // Render HTTP gateway path field 129 | var title = thermometer.id; 130 | if (thermometer.name && thermometer.name.length > 0) { 131 | title += ' (' + thermometer.name + ')'; 132 | } 133 | 134 | elmt = $(renderTextField({name: thermometer.id, title: title, key: 'http.sensor_paths[' + thermometer.id + ']'})); 135 | elmt.val(currentSettings['http.sensor_paths'][thermometer.id]); 136 | pathsContainer.append(elmt); 137 | 138 | elmt = $(renderTextField({name: thermometer.id, title: title, key: thermometer.id})); 139 | elmt.val(thermometer.temperature); 140 | elmt.prop('disabled', true); 141 | temperatureContainer.append(elmt); 142 | }); 143 | } 144 | }; 145 | 146 | var loadThermometers = function() { 147 | $.ajax({ 148 | url: '/thermometers', 149 | dataType: 'json', 150 | success: applyThermometers, 151 | error: showError 152 | }); 153 | }; 154 | 155 | var saveThermometers = function(e) { 156 | e.preventDefault(); 157 | 158 | var data = serializeForm($('#aliases-form')); 159 | data = {"thermometers.aliases":data}; 160 | 161 | $.ajax({ 162 | url: '/settings', 163 | method: 'PUT', 164 | contentType: 'json', 165 | data: JSON.stringify(data) 166 | }); 167 | }; 168 | 169 | var applySettings = function(data) { 170 | currentSettings = data; 171 | 172 | Object.keys(data).forEach(function(key) { 173 | var value = data[key]; 174 | 175 | if (RADIO_FIELDS[key]) { 176 | $('input[name="' + key + '"][value="' + value + '"]').click(); 177 | } else { 178 | $('input[name="' + key + '"]').val(value); 179 | } 180 | }); 181 | }; 182 | 183 | var loadSettings = function() { 184 | $.ajax({ 185 | url: '/settings', 186 | dataType: 'json', 187 | success: function(data) { applySettings(data); loadThermometers(); }, 188 | error: showError 189 | }); 190 | }; 191 | 192 | var saveSettings = function(e) { 193 | e.preventDefault(); 194 | 195 | var data = serializeForm($('#settings-form')); 196 | 197 | $.ajax({ 198 | url: '/settings', 199 | method: 'PUT', 200 | contentType: 'application/json', 201 | data: JSON.stringify(data), 202 | error: showError 203 | }); 204 | }; 205 | 206 | var sendCommand = function(e) { 207 | e.preventDefault(); 208 | 209 | var data = serializeForm(this); 210 | 211 | $.ajax({ 212 | url: '/commands', 213 | method: 'POST', 214 | contentType: 'application/json', 215 | data: JSON.stringify(data), 216 | error: showError 217 | }); 218 | }; 219 | 220 | $(function() { 221 | var keysByCategory = {}; 222 | 223 | // 224 | // Prepare settings. Break up by category 225 | // 226 | SETTING_KEYS.forEach(function(x) { 227 | var keys = x.split('.'); 228 | var category = keys[0]; 229 | var setting = keys[1]; 230 | 231 | if (! keysByCategory[category]) { 232 | keysByCategory[category] = []; 233 | } 234 | 235 | keysByCategory[category].push({key: x, name: setting, title: titleize(setting)}); 236 | }); 237 | 238 | // 239 | // Build settings form 240 | // 241 | Object.keys(keysByCategory).forEach(function(category) { 242 | var elmt = '

' + titleize(category) + '

'; 243 | elmt += '
'; 244 | 245 | keysByCategory[category].forEach(function(setting) { 246 | if (RADIO_FIELDS[setting.key]) { 247 | elmt += renderRadioField(setting); 248 | } else { 249 | elmt += renderTextField(setting); 250 | } 251 | }); 252 | 253 | elmt += '
'; 254 | 255 | $('#settings-form').prepend(elmt); 256 | }); 257 | 258 | // 259 | // Build aliases form 260 | // 261 | 262 | 263 | // 264 | // On-Load setup 265 | // 266 | $('#settings-form btn-radio').button('toggle'); 267 | $('#settings-form').submit(saveSettings); 268 | $('#aliases-form').submit(saveThermometers); 269 | $('.command-form').submit(sendCommand); 270 | 271 | loadSettings(); 272 | }); 273 | 274 | })(jQuery); 275 | )""""; 276 | 277 | #endif -------------------------------------------------------------------------------- /lib/WebStrings/Stylesheet.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _STYLESHEET_H 5 | #define _STYLESHEET_H 6 | 7 | const char STYLESHEET[] PROGMEM = R"( 8 | .thermometer-section { 9 | margin-top: 6em; 10 | } 11 | .page-header { 12 | margin-top: 5em; 13 | } 14 | .page-header h1 { font-size: 4em; } 15 | #banner { border-bottom: 0; } 16 | .category-title { 17 | text-transform: uppercase; 18 | border-bottom: 1px solid #ccc; 19 | font-size: 1.7em; 20 | } 21 | .btn-radio { 22 | display: block; 23 | margin-bottom: 4em; 24 | } 25 | )"; 26 | 27 | #endif -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; http://docs.platformio.org/page/projectconf.html 10 | 11 | [common] 12 | platform = espressif8266@~1.8 13 | lib_deps = 14 | DallasTemperature 15 | OneWire 16 | NtpClientLib@~2.5.1 17 | xoseperez/Time#ecb2bb1 18 | ArduinoJson@~6.10.1 19 | Timezone@~1.2.2 20 | https://github.com/sidoh/WiFiManager#async_support 21 | bbx10/DNSServer_tng#9113193 22 | PubSubClient@~2.7 23 | ESP Async WebServer@~1.2.0 24 | ESPAsyncTCP@~1.2.0 25 | RichHttpServer@~2.0.2 26 | extra_scripts = 27 | lib_ldf_mode = deep 28 | build_flags = 29 | !python3 .get_version.py 30 | -D MQTT_DEBUG 31 | -D RICH_HTTP_ASYNC_WEBSERVER 32 | lib_ignore = 33 | AsyncTCP 34 | 35 | [env:nodemcuv2] 36 | platform = ${common.platform} 37 | board = nodemcuv2 38 | framework = arduino 39 | upload_speed = 460800 40 | extra_scripts = ${common.extra_scripts} 41 | build_flags = ${common.build_flags} -D FIRMWARE_VARIANT=nodemcuv2-4mb 42 | lib_deps = ${common.lib_deps} 43 | lib_ignore = ${common.lib_ignore} 44 | 45 | [env:esp07] 46 | platform = ${common.platform} 47 | board = esp07 48 | framework = arduino 49 | build_flags = ${common.build_flags} -Wl,-Tesp8266.flash.1m64.ld -D FIRMWARE_VARIANT=esp07-1mb 50 | extra_scripts = ${common.extra_scripts} 51 | lib_deps = ${common.lib_deps} 52 | lib_ignore = ${common.lib_ignore} 53 | 54 | [env:esp01] 55 | platform = ${common.platform} 56 | board = esp01 57 | framework = arduino 58 | build_flags = ${common.build_flags} -D FIRMWARE_VARIANT=esp01-512kb 59 | extra_scripts = ${common.extra_scripts} 60 | lib_deps = ${common.lib_deps} 61 | lib_ignore = ${common.lib_ignore} 62 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #if defined(ESP32) 12 | #include 13 | #elif defined(ESP8266) 14 | #include 15 | #define WEBSERVER_H 16 | #endif 17 | #include 18 | 19 | #include 20 | #include 21 | 22 | #include 23 | #include 24 | #include 25 | #include 26 | #include 27 | #include 28 | 29 | MqttClient* mqttClient = NULL; 30 | ThermometerWebserver* server = NULL; 31 | OneWire* oneWire = NULL; 32 | DallasTemperature* sensors = NULL; 33 | Settings settings; 34 | TempIface tempIface(sensors, settings); 35 | time_t lastUpdate = 0; 36 | 37 | enum class OperatingState { UNCHECKED, SETTINGS, NORMAL }; 38 | OperatingState operatingState = OperatingState::UNCHECKED; 39 | 40 | ADC_MODE(ADC_TOUT); 41 | 42 | time_t timestamp() { 43 | TimeChangeRule dstOn = {"DT", Second, dowSunday, Mar, 2, 60}; 44 | TimeChangeRule dstOff = {"ST", First, dowSunday, Nov, 2, 0}; 45 | Timezone timezone(dstOn, dstOff); 46 | 47 | return timezone.toLocal(NTP.getTime()); 48 | } 49 | 50 | void updateTemperature(uint8_t* deviceId, float temp) { 51 | String deviceName = settings.deviceName(deviceId); 52 | String strDeviceId = settings.deviceName(deviceId, false); 53 | 54 | StaticJsonDocument<100> response; 55 | String body; 56 | 57 | response["temperature"] = temp; 58 | response["voltage"] = analogRead(A0); 59 | 60 | serializeJson(response, body); 61 | 62 | if (settings.sensorPaths.count(strDeviceId) > 0) { 63 | HTTPClient http; 64 | 65 | String sensorPath = settings.sensorPaths[strDeviceId]; 66 | time_t now = timestamp(); 67 | String url = String(settings.gatewayServer) + sensorPath; 68 | 69 | http.begin(url); 70 | http.addHeader("Content-Type", "application/json"); 71 | 72 | if (settings.hmacSecret) { 73 | String signature = requestSignature(settings.hmacSecret, sensorPath, body, now); 74 | http.addHeader("X-Signature-Timestamp", String(now)); 75 | http.addHeader("X-Signature", signature); 76 | } 77 | 78 | http.sendRequest("PUT", body); 79 | http.end(); 80 | } 81 | 82 | if (mqttClient != NULL) { 83 | String topic = settings.mqttTopic; 84 | topic += "/"; 85 | topic += deviceName; 86 | topic.replace(" ", "_"); 87 | 88 | mqttClient->sendUpdate(deviceName, body.c_str()); 89 | } 90 | } 91 | 92 | void startSettingsServer() { 93 | server = new ThermometerWebserver(tempIface, settings); 94 | server->begin(); 95 | } 96 | 97 | bool isSettingsMode() { 98 | if (settings.opMode == OperatingMode::ALWAYS_ON) { 99 | operatingState = OperatingState::SETTINGS; 100 | return true; 101 | } 102 | 103 | if (operatingState != OperatingState::UNCHECKED) { 104 | return operatingState == OperatingState::SETTINGS; 105 | } 106 | 107 | if (settings.requiredSettingsDefined()) { 108 | WiFiClient client; 109 | 110 | if (client.connect(settings.flagServer.c_str(), settings.flagServerPort)) { 111 | Serial.println("Connected to flag server"); 112 | String response = client.readString(); 113 | 114 | if (response == "update") { 115 | operatingState = OperatingState::SETTINGS; 116 | } 117 | } else { 118 | Serial.println("Failed to connect to flag server"); 119 | operatingState = OperatingState::NORMAL; 120 | } 121 | } else { 122 | operatingState = OperatingState::SETTINGS; 123 | } 124 | 125 | return operatingState == OperatingState::SETTINGS; 126 | } 127 | 128 | void setup() { 129 | Serial.begin(115200); 130 | Serial.setDebugOutput(true); 131 | 132 | pinMode(A0, INPUT); 133 | 134 | Serial.println(); 135 | Serial.println("Booting Sketch..."); 136 | 137 | if (! SPIFFS.begin()) { 138 | Serial.println("Failed to initialize SPFFS"); 139 | } 140 | 141 | WiFiManager wifiManager; 142 | wifiManager.setConfigPortalTimeout(180); 143 | 144 | char apName[50]; 145 | sprintf(apName, "Thermometer_%d", ESP.getChipId()); 146 | wifiManager.autoConnect(apName, "fireitup"); 147 | 148 | if (!WiFi.isConnected()) { 149 | Serial.println("Timed out trying to connect, going to reboot"); 150 | ESP.restart(); 151 | } 152 | 153 | NTP.begin(); 154 | 155 | Settings::load(settings); 156 | 157 | oneWire = new OneWire(settings.sensorBusPin); 158 | sensors = new DallasTemperature(oneWire); 159 | sensors->begin(); 160 | 161 | tempIface.begin(); 162 | 163 | if (settings._mqttServer.length() > 0) { 164 | mqttClient = new MqttClient(settings); 165 | mqttClient->begin(); 166 | } 167 | 168 | if (isSettingsMode()) { 169 | Serial.println("Entering settings mode"); 170 | startSettingsServer(); 171 | } 172 | } 173 | 174 | void sendUpdates() { 175 | uint8_t addr[8]; 176 | for (uint8_t i = 0; i < sensors->getDeviceCount(); ++i) { 177 | sensors->getAddress(addr, i); 178 | sensors->requestTemperaturesByAddress(addr); 179 | 180 | updateTemperature(addr, sensors->getTempF(addr)); 181 | } 182 | } 183 | 184 | void loop() { 185 | while (!NTP.getFirstSync()) { 186 | yield(); 187 | } 188 | 189 | tempIface.loop(); 190 | 191 | if (isSettingsMode()) { 192 | time_t n = now(); 193 | 194 | if (n > (lastUpdate + settings.updateInterval)) { 195 | sendUpdates(); 196 | lastUpdate = n; 197 | } 198 | } else { 199 | sendUpdates(); 200 | 201 | Serial.println(); 202 | Serial.println("closing connection. going to sleep..."); 203 | 204 | delay(1000); 205 | 206 | ESP.deepSleep(settings.updateInterval * 1000000L, WAKE_RF_DEFAULT); 207 | } 208 | 209 | if (mqttClient) { 210 | mqttClient->handleClient(); 211 | } 212 | } 213 | --------------------------------------------------------------------------------