From b99e393f6a8e1ad673a804cc94b42c962ad71eb6 Mon Sep 17 00:00:00 2001 From: Ian Hubbertz Date: Sat, 31 Mar 2018 10:50:31 +0200 Subject: [PATCH] Upstream develop (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :fire: Remove hardcoded keepalive - fix #301 (#314) Remove hardcoded KeepAlive for MQTT connection. Default value of 15sec is already present in AsyncMQTT library * :racehorse: Improve uptime accuracy (#315) improve uptime accuracy by storing milliseconds and only rounding when publishing value drawback: less time before rollover, but still long enough * :bug: Fix truncated IP (#318) published IP is truncated. * :art: Fix warning with parenthesis * :bug: Fix pio library.json bad dependency name * :sparkles: Add support for static IP and BSSID/MAC, channel of AP (#327) * Support for static IP and BSSID/MAC, Channel of AP - added parameters to config.json which allow to define static ip, mask, gateway, dns - added parameters to config.json which allow to define BSSID and channel of AP To run device with defined static IP you have to define ip, mask and gateway together. To point device to connect to specific BSSID and channel you haveto define bssid and channel together. ``` { "name": "The kitchen light", "device_id": "kitchen-light", "wifi": { "ssid": "Network_1", "password": "I'm a Wi-Fi password!", "bssid": "DE:AD:BE:EF:BA:BE", "channel": 1, "ip": "192.168.1.5", "mask": "255.255.255.0", "gw": "192.168.1.1" }, "mqtt": { "host": "192.168.1.10", "port": 1883, "base_topic": "devices/", "auth": true, "username": "user", "password": "pass" i }, "ota": { "enabled": true }, "settings": { "param1": 55, "param2": "abcdefghijklm", "param3": true, "param4": false, "param5": 2147483647, "param6": -2147483647, "param7": 55, "param8": "abcdefghijklm", "param9": true, "param10": false } } ``` * Addjustments for travis * Another addjustments for travis * Fix problem with prepareToSleep * :racehorse: Pass all callbacks by reference Still store it by value * :racehorse: Make isActive const * :art: Add custom settings value to initial log * :sparkles: Abort if default setting value does not pass validator function Fix #324 * :memo: Clarify ISSUE_TEMPLATE docs location for #331 * :memo: Implement new versioned Git docs (#341) * :sparkles: Add versionned in-repo docs * :bug: Attempt to fix encrypted key * :bug: Fix permission issue on python exec * :bug: Add mission import * :bug: Fix relative path * :bug: Add missing commit * :sparkles: Add index * :art: Change index design * :memo: Update all URLs to new docs * :art: Fix broken doc link in README * :memo: Add edit link to docs * :memo: Move firmware_parser.py to scripts folder * :bug: Fix out of limits abort message not showing * :shirt: Be less strict on whitespace in comments * :memo: Add dummy OTA updater script (TODO) * :art: :racehorse: Use CircleCi instead of Travis CI (#348) * :sparkles: Add Circle CI build * :bug: Add working_directory * :bug: Attempt to fix perm issue * :sparkles: Generate docs from CircleCI * :bug: Fix path * :bug: Fix chmod permission * :bug: Try to run sudo * :bug: Attempt to use remote docker * :bug: Change bad directories * :bug: Remove dependency on Docker * :bug: Fix perm problem * :bug: Chamge tmp folder * :racehorse: Use CircleCI instead of Travis CI * :art: Add SonoffDualShutters example * :bug: Install current lib to platformio * :bug: Actually ignore gh-pages in CI * :bug: Add missing SonoffDual dep * :bug: Fix ArduinoJSON 5.11.0 (#363) * :arrow_up: Update pio dependencies * :art: Cleanup code a bit * :bug: Make sure every announcements packet are sent - closes #345 * :art: Refactor code and implement new OTA system Closes #346 * :shirt: Fix lint * :white_check_mark: Use new workflow feature from Circle * :white_check_mark: Ignore gh-pages at workflow level * :sparkles: Add OTA_PROGRESS event * :memo: Update docs for new OTA system * :arrow_up: Upgrade AsyncMqttClient dependency to 0.8.0 * :bug: IHomieSetting::settings first, HomieNode-settings second! (#335) * IHomieSetting::settings first, HomieNode-settings second! Set initialization priority of IHomieSetting::settings to value of highest allowed priority (101). This allows other static variables to be of type HomieSetting. See https://github.com/euphi/HomieNodeCollection/blob/master/src/RGBWNode.cpp for example. Note: As shown in the example it makes sense to have a static HomieSetting member, if you have a class that may be instantiated multiple times. (e.g. two LED Strips connected to the same ESP8266, as shown in https://github.com/euphi/ESP-LEDCtrl). * Removed extra whitespaces * Removed another whitespace * :bug: Fix not returning a value in setConfigurationApPassword (#378) `HomieClass& HomieClass::setConfigurationApPassword(const char* password)` did not returned a reference to the Homie instance. This PR fixes this. * :bug: Fix crash when starting up without any defined node (#379) * Fix crash when starting up without any defined node Skip node publication if HomieNode::nodes.size() == 0 * Update BootNormal.cpp * :bug: Fix topic check for OTA upload (#375) * Change topic check for OTA upload * Change firmware topics to remove the 's' * :bug: Rename last OTA topic instance * :sparkles: Add OTA updater script (#384) * 🐍Add python ota updater script * 💼 Update documentation of ota update script * 😑 Add comments to ota updater script * 🔮 Use 127.0.0.1:1883 as default broker setting For the ota updater script * :memo: Add details on how to interact with range property (#393) Add hint to help people figure out how to interact with range properties. Especially document the `_` separator. * :memo: Add warning to input-handlers.md about concurrency (#400) * :bug: Interpret firmware file as an bytearray (#403) Fix https://github.com/marvinroger/homie-esp8266/issues/397 * :memo: Update input-handlers.md (#401) * Update input-handlers.md Sorry, it seems that I used the `!!! warning` block in a wrong way. * Update input-handlers.md * Update input-handlers.md * :sparkles: Use AsyncWebServer + Refactoring (#425) * Initial AsyncWebServer * Fixed Proccessing Body Requests (JSON) * Doc Fixes + typo * Added Missing Method in Timer.cpp * Lots of Refactoring + Moved Reset Button to its own helper class for boots to use * :art: Update BootNormal.cpp (#426) Solve "else" errors from https://circleci.com/gh/marvinroger/homie-esp8266/136#tests/containers/0 * :arrow_up: Update dependency to "ESP Async WebServer" (#434) * :memo: Add instruction for @PlatformIO (#435) * Instruction for @PlatformIO * Explain how to used tagged version with @PlatformIO * Explain how to use tagged version with @PlatformIO * :bug: Use v2.0.0-beta.2 as a working tagged example for @PlatformIO (#437) * :art: Simplify CI with @PlatformIO (#438) * :bug: Install library via @PlatformIO with all dependencies (#439) * :green_heart: CI: Install staging version of Arduino Core for ESP8266 & @PlatformIO (#440) * :bug: Pin Shutters dep version * :fire: Don't fail on CI docs step when testing a fork * :art: More Refactoring + Deep Sleep + Prevent WiFi Reconnect when reboot (#432) * Initial AsyncWebServer * Fixed Proccessing Body Requests (JSON) * Doc Fixes + typo * Added Missing Method in Timer.cpp * Lots of Refactoring + Moved Reset Button to its own helper class for boots to use * Lots of Refactoring. * Refactored ResetHandler + More Refactoring + Testing * Added deep sleep function + Prevent Wifi reconnect before reboot (#380)[https://github.com/marvinroger/homie-esp8266/pull/380] * Small typo fixes * Small Rearange of code in BootConfig + Minor Refactor of function names * Minior Commit to Triger a Github Action * circleci build fix * :art: First pass to fix linting * :art: Second pass of lint * :art: Final lint fix * :memo: Update docs deps * :memo: :art: Update docs manifest * :memo: :art: Adjust HTTP JSON API doc * :memo: Add configurators on website * :memo: Update links to configurator * Fix links under Features (#452) * Set Device Stats Interval (#451) (#455) * Add last step to uibundle README (#460) * Add last step to uibundle README * Added Arduino Support for doc * Update updater script addressing quirks (#461) * Update API for /wifi/connect from GET to PUT (#468) Docs show `/wifi/connect` as `GET` when it should actually be `PUT` * Proposal to optionally run HomieNode::loop() also in disconnected state * Show Homie version * Fix #446 #477 (#501) * Fix #446 CORS Issue * Fix for #477 * Fix Lint * Fix Safari not displaying the config bundle HTML page (Fix #476) (#502) * Fix Safari not displaying the config bundle HTML page Safari cannot deal with gzip files that have a "*.gz" file extension. Simply faking the filename solves the problem though. * Update Readme Homie Version * Fix Warnings (#503) * Update Readme - Homie Convention * Update to Homie Convention v2.0.1 (#507) --- .circleci/assets/circleci.ignore.yml | 11 + .circleci/assets/configurator_v1.html | 33 + .circleci/assets/docs_index_template.html | 75 ++ .circleci/assets/generate_docs.py | 130 ++ .circleci/assets/id_rsa.enc | Bin 0 -> 3264 bytes .circleci/assets/mkdocs.default.yml | 29 + .circleci/config.yml | 101 ++ .github/ISSUE_TEMPLATE.md | 2 +- .gitignore | 5 + .travis.yml | 24 - Makefile | 2 +- README.md | 61 +- data/homie/README.md | 4 +- docs/README.md | 7 + docs/advanced-usage/branding.md | 8 + docs/advanced-usage/broadcast.md | 13 + docs/advanced-usage/built-in-led.md | 19 + docs/advanced-usage/custom-settings.md | 38 + docs/advanced-usage/deep-sleep.md | 29 + docs/advanced-usage/events.md | 69 + docs/advanced-usage/input-handlers.md | 63 + docs/advanced-usage/logging.md | 26 + docs/advanced-usage/magic-bytes.md | 16 + docs/advanced-usage/miscellaneous.md | 63 + docs/advanced-usage/range-properties.md | 31 + docs/advanced-usage/resetting.md | 35 + docs/advanced-usage/standalone-mode.md | 12 + docs/advanced-usage/streaming-operator.md | 17 + docs/advanced-usage/ui-bundle.md | 3 + docs/assets/github.png | Bin 0 -> 2944 bytes docs/assets/led_mqtt.gif | Bin 0 -> 324 bytes docs/assets/led_solid.gif | Bin 0 -> 253 bytes docs/assets/led_wifi.gif | Bin 0 -> 324 bytes docs/assets/logo.png | Bin 0 -> 26042 bytes docs/assets/youtube.png | Bin 0 -> 2635 bytes docs/configuration/http-json-api.md | 264 ++++ docs/configuration/json-configuration-file.md | 59 + docs/index.md | 1 + docs/others/community-projects.md | 19 + docs/others/cpp-api-reference.md | 323 +++++ docs/others/homie-implementation-specifics.md | 30 + docs/others/limitations-and-known-issues.md | 11 + docs/others/ota-configuration-updates.md | 61 + docs/others/troubleshooting.md | 37 + docs/others/upgrade-guide-from-v1-to-v2.md | 15 + docs/quickstart/getting-started.md | 142 ++ docs/quickstart/what-is-it.md | 3 + examples/HookToEvents/HookToEvents.ino | 5 +- .../SonoffDualShutters/SonoffDualShutters.ino | 128 ++ keywords.txt | 4 +- library.json | 20 +- mkdocs.yml | 76 ++ scripts/firmware_parser/README.md | 8 + .../firmware_parser/firmware_parser.py | 2 +- scripts/ota_updater/README.md | 48 + scripts/ota_updater/ota_updater.py | 155 +++ scripts/ota_updater/requirements.txt | 1 + src/Homie.cpp | 118 +- src/Homie.hpp | 15 +- src/Homie/Boot/BootConfig.cpp | 474 +++---- src/Homie/Boot/BootConfig.hpp | 29 +- src/Homie/Boot/BootNormal.cpp | 1147 ++++++++++------- src/Homie/Boot/BootNormal.hpp | 59 +- src/Homie/Boot/BootStandalone.cpp | 41 +- src/Homie/Boot/BootStandalone.hpp | 10 +- src/Homie/Config.cpp | 170 ++- src/Homie/Constants.hpp | 8 +- src/Homie/Datatypes/ConfigStruct.hpp | 8 + src/Homie/Datatypes/Interface.cpp | 28 +- src/Homie/Datatypes/Interface.hpp | 6 +- src/Homie/ExponentialBackoffTimer.cpp | 42 + src/Homie/ExponentialBackoffTimer.hpp | 22 + src/Homie/Limits.hpp | 28 +- src/Homie/Logger.cpp | 6 +- src/Homie/Strings.hpp | 5 +- src/Homie/TimedRetry.cpp | 47 - src/Homie/TimedRetry.hpp | 20 - src/Homie/Timer.cpp | 13 +- src/Homie/Uptime.cpp | 6 +- src/Homie/Uptime.hpp | 2 +- src/Homie/Utils/Helpers.cpp | 59 + src/Homie/Utils/Helpers.hpp | 10 +- src/Homie/Utils/ResetHandler.cpp | 51 + src/Homie/Utils/ResetHandler.hpp | 25 + src/Homie/Utils/Validation.cpp | 173 ++- src/Homie/Utils/Validation.hpp | 1 + src/HomieEvent.hpp | 6 +- src/HomieNode.cpp | 19 +- src/HomieNode.hpp | 12 +- src/HomieSetting.cpp | 60 +- src/HomieSetting.hpp | 30 +- src/SendingPromise.cpp | 2 +- 92 files changed, 3983 insertions(+), 1107 deletions(-) create mode 100644 .circleci/assets/circleci.ignore.yml create mode 100755 .circleci/assets/configurator_v1.html create mode 100644 .circleci/assets/docs_index_template.html create mode 100644 .circleci/assets/generate_docs.py create mode 100644 .circleci/assets/id_rsa.enc create mode 100644 .circleci/assets/mkdocs.default.yml create mode 100644 .circleci/config.yml delete mode 100644 .travis.yml create mode 100644 docs/README.md create mode 100644 docs/advanced-usage/branding.md create mode 100644 docs/advanced-usage/broadcast.md create mode 100644 docs/advanced-usage/built-in-led.md create mode 100644 docs/advanced-usage/custom-settings.md create mode 100644 docs/advanced-usage/deep-sleep.md create mode 100644 docs/advanced-usage/events.md create mode 100644 docs/advanced-usage/input-handlers.md create mode 100644 docs/advanced-usage/logging.md create mode 100644 docs/advanced-usage/magic-bytes.md create mode 100644 docs/advanced-usage/miscellaneous.md create mode 100644 docs/advanced-usage/range-properties.md create mode 100644 docs/advanced-usage/resetting.md create mode 100644 docs/advanced-usage/standalone-mode.md create mode 100644 docs/advanced-usage/streaming-operator.md create mode 100644 docs/advanced-usage/ui-bundle.md create mode 100644 docs/assets/github.png create mode 100644 docs/assets/led_mqtt.gif create mode 100644 docs/assets/led_solid.gif create mode 100644 docs/assets/led_wifi.gif create mode 100644 docs/assets/logo.png create mode 100644 docs/assets/youtube.png create mode 100644 docs/configuration/http-json-api.md create mode 100644 docs/configuration/json-configuration-file.md create mode 100644 docs/index.md create mode 100644 docs/others/community-projects.md create mode 100644 docs/others/cpp-api-reference.md create mode 100644 docs/others/homie-implementation-specifics.md create mode 100644 docs/others/limitations-and-known-issues.md create mode 100644 docs/others/ota-configuration-updates.md create mode 100644 docs/others/troubleshooting.md create mode 100644 docs/others/upgrade-guide-from-v1-to-v2.md create mode 100644 docs/quickstart/getting-started.md create mode 100644 docs/quickstart/what-is-it.md create mode 100644 examples/SonoffDualShutters/SonoffDualShutters.ino create mode 100644 mkdocs.yml create mode 100644 scripts/firmware_parser/README.md rename firmware_parser.py => scripts/firmware_parser/firmware_parser.py (98%) create mode 100644 scripts/ota_updater/README.md create mode 100755 scripts/ota_updater/ota_updater.py create mode 100644 scripts/ota_updater/requirements.txt create mode 100644 src/Homie/ExponentialBackoffTimer.cpp create mode 100644 src/Homie/ExponentialBackoffTimer.hpp delete mode 100644 src/Homie/TimedRetry.cpp delete mode 100644 src/Homie/TimedRetry.hpp create mode 100644 src/Homie/Utils/ResetHandler.cpp create mode 100644 src/Homie/Utils/ResetHandler.hpp diff --git a/.circleci/assets/circleci.ignore.yml b/.circleci/assets/circleci.ignore.yml new file mode 100644 index 00000000..59c18e15 --- /dev/null +++ b/.circleci/assets/circleci.ignore.yml @@ -0,0 +1,11 @@ +version: 2 +jobs: + build: + working_directory: ~/code + docker: + - image: circleci/python:2.7 + branches: + ignore: + - gh-pages + steps: + - checkout diff --git a/.circleci/assets/configurator_v1.html b/.circleci/assets/configurator_v1.html new file mode 100755 index 00000000..cd634062 --- /dev/null +++ b/.circleci/assets/configurator_v1.html @@ -0,0 +1,33 @@ + + + + + + + + Set up your Homie for ESP8266 device + + + + + + + + + + +
+
+ + + + diff --git a/.circleci/assets/docs_index_template.html b/.circleci/assets/docs_index_template.html new file mode 100644 index 00000000..6c874f08 --- /dev/null +++ b/.circleci/assets/docs_index_template.html @@ -0,0 +1,75 @@ + + + + Homie for ESP8266 docs + + + + + + + +
+

Homie for ESP8266 docs

+ +

Configurators

+ + $configurators_html + +

Documentation

+ + $documentation_html +
+ + diff --git a/.circleci/assets/generate_docs.py b/.circleci/assets/generate_docs.py new file mode 100644 index 00000000..ecfc2fe4 --- /dev/null +++ b/.circleci/assets/generate_docs.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import json +import urllib +import urllib2 +import tempfile +import zipfile +import glob +import subprocess +import getopt +import sys +import shutil +import os +import string + +FIRST_RELEASE_ID=3084382 +DOCS_PATH = 'docs' +DOCS_BRANCHES = [ + { 'tag': 'develop', 'description': 'develop branch (development)', 'path': 'develop' }, + { 'tag': 'master', 'description': 'master branch (stable)', 'path': 'stable' } +] +CONFIGURATORS_PATH = 'configurators' +CONFIGURATORS_VERSIONS = [ + { 'title': 'v2', 'description': 'For Homie v2.x.x', 'path': 'v2', 'url': 'https://github.com/marvinroger/homie-esp8266-setup/raw/gh-pages/ui_bundle.html' }, + { 'title': 'v1', 'description': 'For Homie v1.x.x', 'path': 'v1', 'file': '/configurator_v1.html' } +] + +current_dir = os.path.dirname(__file__) +output_dir = getopt.getopt(sys.argv[1:], 'o:')[0][0][1] +github_releases = json.load(urllib2.urlopen('https://api.github.com/repos/marvinroger/homie-esp8266/releases')) + +def generate_docs(data): + print('Generating docs for ' + data['tag'] + ' (' + data['description'] + ') at /' + data['path'] + '...') + zip_url = 'https://github.com/marvinroger/homie-esp8266/archive/' + data['tag'] + '.zip' + zip_path = tempfile.mkstemp()[1] + urllib.urlretrieve(zip_url, zip_path) + + zip_file = zipfile.ZipFile(zip_path, 'r') + unzip_path = tempfile.mkdtemp() + zip_file.extractall(unzip_path) + src_path = glob.glob(unzip_path + '/*')[0] + + if not os.path.isfile(src_path + '/mkdocs.yml'): shutil.copy(current_dir + '/mkdocs.default.yml', src_path + '/mkdocs.yml') + + subprocess.call(['mkdocs', 'build'], cwd=src_path) + shutil.copytree(src_path + '/site', output_dir + '/' + DOCS_PATH + '/' + data['path']) + print('Done.') + +def generate_configurators(data): + print('Generating configurator for ' + data['title'] + ' (' + data['description'] + ') at /' + data['path'] + '...') + file_path = None + if 'file' in data: + file_path = current_dir + data['file'] + else: # url + file_path = tempfile.mkstemp()[1] + urllib.urlretrieve(data['url'], file_path) + + prefix_output = output_dir + '/' + CONFIGURATORS_PATH + '/' + data['path'] + try: + os.makedirs(prefix_output) + except: + pass + + shutil.copy(file_path, prefix_output + '/index.html') + + print('Done.') + +shutil.rmtree(output_dir, ignore_errors=True) + +# Generate docs + +generated_docs = [] + +# Generate docs for branches + +for branch in DOCS_BRANCHES: + generated_docs.append(branch) + generate_docs(branch) + +# Generate docs for releases + +for release in github_releases: + if (release['id'] < FIRST_RELEASE_ID): continue + + tag_name = release['tag_name'] + version = tag_name[1:] + description = 'release ' + version + + data = { + 'tag': tag_name, + 'description': description, + 'path': version + } + + generated_docs.append(data) + generate_docs(data) + +# Generate documentation html + +documentation_html = '' + +# Generate configurators + +generated_configurators = [] + +for version in CONFIGURATORS_VERSIONS: + generated_configurators.append(version) + generate_configurators(version) + +# Generate configurators html + +configurators_html = '' + +# Generate index + +docs_index_template_file = open(current_dir + '/docs_index_template.html') +docs_index_template_html = docs_index_template_file.read() +docs_index_template = string.Template(docs_index_template_html) +docs_index = docs_index_template.substitute(documentation_html=documentation_html, configurators_html=configurators_html) + +docs_index_file = open(output_dir + '/index.html', 'w') +docs_index_file.write(docs_index) +docs_index_file.close() diff --git a/.circleci/assets/id_rsa.enc b/.circleci/assets/id_rsa.enc new file mode 100644 index 0000000000000000000000000000000000000000..c8b23381e7ef4f4a7ee738260702a6574f466b6c GIT binary patch literal 3264 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 literal 0 HcmV?d00001 diff --git a/.circleci/assets/mkdocs.default.yml b/.circleci/assets/mkdocs.default.yml new file mode 100644 index 00000000..2a31cff2 --- /dev/null +++ b/.circleci/assets/mkdocs.default.yml @@ -0,0 +1,29 @@ +site_name: Homie for ESP8266 +repo_name: 'marvinroger/homie-esp8266' +repo_url: 'https://github.com/marvinroger/homie-esp8266' + +theme: + name: material + palette: + primary: red + accent: red + +markdown_extensions: + - meta + - footnotes + - codehilite + - admonition + - toc(permalink=true) + - pymdownx.arithmatex + - pymdownx.betterem(smart_enable=all) + - pymdownx.caret + - pymdownx.critic + - pymdownx.emoji: + emoji_generator: !!python/name:pymdownx.emoji.to_svg + - pymdownx.inlinehilite + - pymdownx.magiclink + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences + - pymdownx.tasklist(custom_checkbox=true) + - pymdownx.tilde diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..8fee20fc --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,101 @@ +version: 2 +jobs: + build: + working_directory: ~/code + docker: + - image: circleci/python:2.7 + steps: + - checkout + - run: + name: install PlatformIO + command: sudo pip install -U https://github.com/platformio/platformio-core/archive/develop.zip + - run: + name: install current code as a PlatformIO library with all dependencies + command: platformio lib -g install file://. + - run: + name: install staging version of Arduino Core for ESP8266 + command: platformio platform install https://github.com/platformio/platform-espressif8266.git#feature/stage + - run: + name: install exemples dependencies + command: platformio lib -g install Shutters@2.1.1 SonoffDual@1.1.0 + - run: platformio ci ./examples/CustomSettings --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/DoorSensor --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/HookToEvents --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/IteadSonoff --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/LightOnOff --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/TemperatureSensor --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/LedStrip --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/Broadcast --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/GlobalInputHandler --board=esp01 --board=nodemcuv2 + - run: platformio ci ./examples/SonoffDualShutters --board=esp01 --board=nodemcuv2 + + lint: + working_directory: ~/code + docker: + - image: circleci/python:2.7 + steps: + - checkout + - run: + name: install cpplint + command: sudo pip install cpplint + - run: make cpplint + + generate_docs: + working_directory: ~/code + docker: + - image: circleci/python:2.7 + steps: + - checkout + - run: + name: install dependencies + command: sudo pip install mkdocs==0.17.2 mkdocs-material==2.2.0 pygments==2.2.0 pymdown-extensions==4.5.1 + - run: + name: generate and publish docs + command: | + if [ -z ${PRIVATE_KEY_ENCRYPT_KEY+x} ] + then + echo "Fork detected. Ignoring..." + exit 0 + fi + + openssl aes-256-cbc -d -in ./.circleci/assets/id_rsa.enc -k "${PRIVATE_KEY_ENCRYPT_KEY}" >> /tmp/deploy_rsa + eval "$(ssh-agent -s)" + chmod 600 /tmp/deploy_rsa + ssh-add /tmp/deploy_rsa + + chmod +x ./.circleci/assets/generate_docs.py + ./.circleci/assets/generate_docs.py -o /tmp/site + + # make sure we ignore the gh-pages branch + mkdir /tmp/site/.circleci + cp ./.circleci/assets/circleci.ignore.yml /tmp/site/.circleci/config.yml + + pushd /tmp/site + git init + git config --global user.name "circleci" + git config --global user.email "sayhi@circleci.com" + git remote add origin git@github.com:marvinroger/homie-esp8266.git + git add . + git commit -m ":package: Result of CircleCI build ${CIRCLE_BUILD_URL}" + git push -f origin master:gh-pages + popd + +workflows: + version: 2 + lint_build_generatedocs: + jobs: + - lint: + filters: + branches: + ignore: + - gh-pages + - build: + filters: + branches: + ignore: + - gh-pages + - generate_docs: + filters: + branches: + ignore: + - gh-pages diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index ffc87606..c23dcb1c 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -3,7 +3,7 @@ If you have some questions or if you need help, some people might help you on th Before submitting your issue, make sure: -- [ ] You've read the documentation for *your* release (in the `docs/` folder for the v1, at https://homie-esp8266.readme.io) which contains some answsers to the most common problems (notably the `Limitations and know issues` and `Troubleshooting` pages) +- [ ] You've read the documentation for *your* release (in the `docs/` folder or at http://marvinroger.github.io/homie-esp8266/) which contains some answsers to the most common problems (notably the `Limitations and know issues` and `Troubleshooting` pages) - [ ] You're using the examples bundled in *your* release, which are in the `examples/` folder of the `.zip` of the release you're using. Examples might not be backward-compatible Thanks! diff --git a/.gitignore b/.gitignore index 7d3a24e1..ebb6f739 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ +# Output of mkdocs +/site/ + /config.json +*.filters +*.vcxitems diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4198ed5a..00000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: python -python: - - "2.7" - -env: - - PLATFORMIO_CI_SRC=examples/CustomSettings - - PLATFORMIO_CI_SRC=examples/DoorSensor - - PLATFORMIO_CI_SRC=examples/HookToEvents - - PLATFORMIO_CI_SRC=examples/IteadSonoff - - PLATFORMIO_CI_SRC=examples/LightOnOff - - PLATFORMIO_CI_SRC=examples/TemperatureSensor - - PLATFORMIO_CI_SRC=examples/LedStrip - - PLATFORMIO_CI_SRC=examples/Broadcast - - PLATFORMIO_CI_SRC=examples/GlobalInputHandler - - CPPLINT=true - -install: - - pip install -U platformio - - pip install cpplint - # install current build as a library with all dependencies - - platformio lib -g install file://. - -script: - - if [[ "$CPPLINT" ]]; then make cpplint; else platformio ci --board=esp01 --board=nodemcuv2; fi diff --git a/Makefile b/Makefile index 8afc5fa9..c8c1cc9b 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,3 @@ cpplint: - cpplint --repository=. --recursive --filter=-whitespace/line_length,-legal/copyright,-runtime/printf,-build/include,-build/namespace,-runtime/int ./src + cpplint --repository=. --recursive --filter=-whitespace/line_length,-legal/copyright,-runtime/printf,-build/include,-build/namespace,-runtime/int,-whitespace/comments,-runtime/threadsafe_fn ./src .PHONY: cpplint diff --git a/README.md b/README.md index fa5bcb3d..a4299de2 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,68 @@ Homie for ESP8266 ================= -[![Build Status](https://img.shields.io/travis/marvinroger/homie-esp8266/develop.svg?style=flat-square)](https://travis-ci.org/marvinroger/homie-esp8266) [![Latest Release](https://img.shields.io/badge/release-v2.0.0-yellow.svg?style=flat-square)](https://github.com/marvinroger/homie-esp8266/releases) [![Gitter](https://img.shields.io/gitter/room/Homie/ESP8266.svg?style=flat-square)](https://gitter.im/homie-iot/ESP8266) +[![Build Status](https://img.shields.io/circleci/project/github/marvinroger/homie-esp8266/develop.svg?style=flat-square)](https://circleci.com/gh/marvinroger/homie-esp8266) [![Latest Release](https://img.shields.io/badge/release-v2.0.0-yellow.svg?style=flat-square)](https://github.com/marvinroger/homie-esp8266/releases) [![Gitter](https://img.shields.io/gitter/room/Homie/ESP8266.svg?style=flat-square)](https://gitter.im/homie-iot/ESP8266) An Arduino for ESP8266 implementation of [Homie](https://github.com/marvinroger/homie), an MQTT convention for the IoT. -## Note for v1.x users +Currently Homie for ESP8266 implements [Homie 2.0.1](https://github.com/marvinroger/homie/releases/tag/v2.0.1) -The new documentation and Web configurator are only for the v2. +## Note for v1.x users -* The docs for the v1.5 is available at https://github.com/marvinroger/homie-esp8266/blob/528a4f77c6371366847ebf4def6aba942dfd0c4c/docs/index.md -* The Web configurator for v1.5 is available at http://marvinroger.github.io/homie-esp8266/ +The old configurator is not available online anymore. You can download it [here](https://github.com/marvinroger/homie-esp8266/releases/download/v1.5.0/homie-esp8266-v1-setup.zip). ## Download The Git repository contains the development version of Homie for ESP8266. Stable releases are available [on the releases page](https://github.com/marvinroger/homie-esp8266/releases). +## Using with PlatformIO + +[PlatformIO](http://platformio.org) is an open source ecosystem for IoT development with cross platform build system, library manager and full support for Espressif ESP8266 development. It works on the popular host OS: Mac OS X, Windows, Linux 32/64, Linux ARM (like Raspberry Pi, BeagleBone, CubieBoard). + +1. Install [PlatformIO IDE](http://platformio.org/platformio-ide) +2. Create new project using "PlatformIO Home > New Project" +3. Open [Project Configuration File `platformio.ini`](http://docs.platformio.org/page/projectconf.html) + +### Stable version + +4. Add "Homie" to project using `platformio.ini` and [lib_deps](http://docs.platformio.org/page/projectconf/section_env_library.html#lib-deps) option: +```ini +[env:myboard] +platform = espressif8266 +board = ... +framework = arduino +lib_deps = Homie +``` + +### Development version + +4. Update dev/platform to staging version: + - [Instruction for Espressif 8266](http://docs.platformio.org/en/latest/platforms/espressif8266.html#using-arduino-framework-with-staging-version) +5. Add development version of "Homie" to project using `platformio.ini` and [lib_deps](http://docs.platformio.org/page/projectconf/section_env_library.html#lib-deps) option: +```ini +[env:myboard] +platform = ... +board = ... +framework = arduino + +; the latest development branch +lib_deps = https://github.com/marvinroger/homie-esp8266.git + +; or tagged version +lib_deps = https://github.com/marvinroger/homie-esp8266.git#v2.0.0-beta.2 +``` + +----- +Happy coding with PlatformIO! + ## Features * Automatic connection/reconnection to Wi-Fi/MQTT -* [JSON configuration file](https://homie-esp8266.readme.io/v2.0.0/docs/json-configuration-file) to configure the device -* [Cute HTTP API / Web UI / App](https://homie-esp8266.readme.io/v2.0.0/docs/http-json-api) to remotely send the configuration to the device and get information about it -* [Custom settings](https://homie-esp8266.readme.io/v2.0.0/docs/custom-settings) -* [OTA over MQTT](https://homie-esp8266.readme.io/v2.0.0/docs/ota-configuration-updates) -* [Magic bytes](https://homie-esp8266.readme.io/v2.0.0/docs/magic-bytes) +* [JSON configuration file](http://marvinroger.github.io/homie-esp8266/docs/develop/configuration/json-configuration-file) to configure the device +* [Cute HTTP API / Web UI / App](http://marvinroger.github.io/homie-esp8266/docs/develop/configuration/http-json-api) to remotely send the configuration to the device and get information about it +* [Custom settings](http://marvinroger.github.io/homie-esp8266/docs/develop/advanced-usage/custom-settings) +* [OTA over MQTT](http://marvinroger.github.io/homie-esp8266/docs/develop/others/ota-configuration-updates) +* [Magic bytes](http://marvinroger.github.io/homie-esp8266/docs/develop/advanced-usage/magic-bytes) * Available in the [PlatformIO registry](http://platformio.org/#!/lib/show/555/Homie) * Pretty straightforward sketches, a simple light for example: @@ -67,7 +106,7 @@ void loop() { ## Requirements, installation and usage -The project is documented on https://homie-esp8266.readme.io with a *Getting started* guide and every piece of information you will need. +The project is documented on http://marvinroger.github.io/homie-esp8266/ with a *Getting started* guide and every piece of information you will need. ## Donate diff --git a/data/homie/README.md b/data/homie/README.md index 868170ad..8ab0a3a9 100644 --- a/data/homie/README.md +++ b/data/homie/README.md @@ -4,7 +4,9 @@ This folder contains the data you can upload to the SPIFFS of your ESP8266. This is optional. -To upload files to the SPIFFS of your device, create a folder named `data` in your sketch directory. In this `data` folder, create an `homie` directory. You can put two files in it: +To upload files to the SPIFFS of your device, first create a folder named `data` in your sketch directory. In this `data` folder, create an `homie` directory. You can put two files in it: 1. The `config.json` file, if you want to bypass the `configuration` mode. 2. The `ui_bundle.gz` file, that you can download [here](http://setup.homie-esp8266.marvinroger.fr/ui_bundle.gz). If present, the configuration UI will be served directly from the ESP8266. + +Finally initiate the [SPIFFS upload process](http://docs.platformio.org/en/stable/platforms/espressif8266.html?highlight=spiffs#uploading-files-to-file-system-spiffs) via PlatformIO, or via the [Arduino IDE](http://esp8266.github.io/Arduino/versions/2.3.0/doc/filesystem.html#uploading-files-to-file-system) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..aa561b45 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +Docs +==== + +Docs are available: + +* Locally at [index.md](index.md) +* Online at http://marvinroger.github.io/homie-esp8266/ diff --git a/docs/advanced-usage/branding.md b/docs/advanced-usage/branding.md new file mode 100644 index 00000000..a2a1e1ee --- /dev/null +++ b/docs/advanced-usage/branding.md @@ -0,0 +1,8 @@ +By default, Homie for ESP8266 will spawn an `Homie-xxxxxxxxxxxx` AP and will connect to the MQTT broker with the `Homie-xxxxxxxxxxxx` client ID. You might want to change the `Homie` text: + +```c++ +void setup() { + Homie_setBrand("MyIoTSystem"); // before Homie.setup() + // ... +} +``` diff --git a/docs/advanced-usage/broadcast.md b/docs/advanced-usage/broadcast.md new file mode 100644 index 00000000..f77e452b --- /dev/null +++ b/docs/advanced-usage/broadcast.md @@ -0,0 +1,13 @@ +Your device can react to Homie broadcasts. To do that, you can use a broadcast handler: + +```c++ +bool broadcastHandler(const String& level, const String& value) { + Serial << "Received broadcast level " << level << ": " << value << endl; + return true; +} + +void setup() { + Homie.setBroadcastHandler(broadcastHandler); // before Homie.setup() + // ... +} +``` diff --git a/docs/advanced-usage/built-in-led.md b/docs/advanced-usage/built-in-led.md new file mode 100644 index 00000000..d5fc54a0 --- /dev/null +++ b/docs/advanced-usage/built-in-led.md @@ -0,0 +1,19 @@ +By default, Homie for ESP8266 will blink the built-in LED to indicate its status. Note it does not indicate activity, only the status of the device (in `configuration` mode, connecting to Wi-Fi or connecting to MQTT), see [Getting started](../quickstart/getting-started.md) for more information. + +However, on some boards like the ESP-01, the built-in LED is actually the TX port, so it is fine if Serial is not enabled, but if you enable Serial, this is a problem. You can easily disable the built-in LED blinking. + +```c++ +void setup() { + Homie.disableLedFeedback(); // before Homie.setup() + // ... +} +``` + +You may, instead of completely disable the LED control, set a new LED to control: + +```c++ +void setup() { + Homie.setLedPin(16, HIGH); // before Homie.setup() -- 2nd param is the state of the pin when the LED is o + // ... +} +``` diff --git a/docs/advanced-usage/custom-settings.md b/docs/advanced-usage/custom-settings.md new file mode 100644 index 00000000..03b29083 --- /dev/null +++ b/docs/advanced-usage/custom-settings.md @@ -0,0 +1,38 @@ +Homie for ESP8266 lets you implement custom settings that can be set from the JSON configuration file and the Configuration API. Below is an example of how to use this feature: + +```c++ +HomieSetting percentageSetting("percentage", "A simple percentage"); // id, description + +void setup() { + percentageSetting.setDefaultValue(50).setValidator([] (long candidate) { + return (candidate >= 0) && (candidate <= 100); + }); + + Homie.setup(); +} +``` + +An `HomieSetting` instance can be of the following types: + +Type | Value +---- | ----- +`bool` | `true` or `false` +`long` | An integer from `-2,147,483,648` to `2,147,483,647` +`double` | A floating number that can fit into a `real64_t` +`const char*` | Any string + +By default, a setting is mandatory (you have to set it in the configuration file). If you give it a default value with `setDefaultValue()`, the setting becomes optional. You can validate a setting by giving a validator function to `setValidator()`. To get the setting from your code, use `get()`. To get whether the value returned is the optional one or the one provided, use `wasProvided()`. + +For this example, if you want to provide the `percentage` setting, you will have to put in your configuration file: + +```json +{ + "settings": { + "percentage": 75 + } +} +``` + +See the following example for a concrete use case: + +[![GitHub logo](../assets/github.png) CustomSettings.ino](https://github.com/marvinroger/homie-esp8266/blob/develop/examples/CustomSettings/CustomSettings.ino) diff --git a/docs/advanced-usage/deep-sleep.md b/docs/advanced-usage/deep-sleep.md new file mode 100644 index 00000000..734da88b --- /dev/null +++ b/docs/advanced-usage/deep-sleep.md @@ -0,0 +1,29 @@ +Before deep sleeping, you will want to ensure that all messages are sent, including the `$online → false`. To do that, you can call `Homie.prepareToSleep()`. This will disconnect everything cleanly, so that you can call `ESP.deepSleep()`. + +```c++ +#include + +void onHomieEvent(const HomieEvent& event) { + switch(event.type) { + case HomieEventType::MQTT_READY: + Homie.getLogger() << "MQTT connected, preparing for deep sleep..." << endl; + Homie.prepareToSleep(); + break; + case HomieEventType::READY_TO_SLEEP: + Homie.getLogger() << "Ready to sleep" << endl; + Homie.doDeepSleep(); + break; + } +} + +void setup() { + Serial.begin(115200); + Serial << endl << endl; + Homie.onEvent(onHomieEvent); + Homie.setup(); +} + +void loop() { + Homie.loop(); +} +``` diff --git a/docs/advanced-usage/events.md b/docs/advanced-usage/events.md new file mode 100644 index 00000000..7ab6ce48 --- /dev/null +++ b/docs/advanced-usage/events.md @@ -0,0 +1,69 @@ +You may want to hook to Homie events. Maybe you will want to control an RGB LED if the Wi-Fi connection is lost, or execute some code prior to a device reset, for example to clear some EEPROM you're using: + +```c++ +void onHomieEvent(const HomieEvent& event) { + switch(event.type) { + case HomieEventType::STANDALONE_MODE: + // Do whatever you want when standalone mode is started + break; + case HomieEventType::CONFIGURATION_MODE: + // Do whatever you want when configuration mode is started + break; + case HomieEventType::NORMAL_MODE: + // Do whatever you want when normal mode is started + break; + case HomieEventType::OTA_STARTED: + // Do whatever you want when OTA is started + break; + case HomieEventType::OTA_PROGRESS: + // Do whatever you want when OTA is in progress + + // You can use event.sizeDone and event.sizeTotal + break; + case HomieEventType::OTA_FAILED: + // Do whatever you want when OTA is failed + break; + case HomieEventType::OTA_SUCCESSFUL: + // Do whatever you want when OTA is successful + break; + case HomieEventType::ABOUT_TO_RESET: + // Do whatever you want when the device is about to reset + break; + case HomieEventType::WIFI_CONNECTED: + // Do whatever you want when Wi-Fi is connected in normal mode + + // You can use event.ip, event.gateway, event.mask + break; + case HomieEventType::WIFI_DISCONNECTED: + // Do whatever you want when Wi-Fi is disconnected in normal mode + + // You can use event.wifiReason + break; + case HomieEventType::MQTT_READY: + // Do whatever you want when MQTT is connected in normal mode + break; + case HomieEventType::MQTT_DISCONNECTED: + // Do whatever you want when MQTT is disconnected in normal mode + + // You can use event.mqttReason + break; + case HomieEventType::MQTT_PACKET_ACKNOWLEDGED: + // Do whatever you want when an MQTT packet with QoS > 0 is acknowledged by the broker + + // You can use event.packetId + break; + case HomieEventType::READY_TO_SLEEP: + // After you've called `prepareToSleep()`, the event is triggered when MQTT is disconnected + break; + } +} + +void setup() { + Homie.onEvent(onHomieEvent); // before Homie.setup() + // ... +} +``` + +See the following example for a concrete use case: + +[![GitHub logo](../assets/github.png) HookToEvents.ino](https://github.com/marvinroger/homie-esp8266/blob/develop/examples/HookToEvents/HookToEvents.ino) diff --git a/docs/advanced-usage/input-handlers.md b/docs/advanced-usage/input-handlers.md new file mode 100644 index 00000000..0efe4124 --- /dev/null +++ b/docs/advanced-usage/input-handlers.md @@ -0,0 +1,63 @@ +There are four types of input handlers: + +* Global input handler. This unique handler will handle every changed settable properties for all nodes + +```c++ +bool globalInputHandler(const HomieNode& node, const String& property, const HomieRange& range, const String& value) { + +} + +void setup() { + Homie.setGlobalInputHandler(globalInputHandler); // before Homie.setup() + // ... +} +``` + +* Node input handlers. This handler will handle every changed settable properties of a specific node + +```c++ +bool nodeInputHandler(const String& property, const HomieRange& range, const String& value) { + +} + +HomieNode node("id", "type", nodeInputHandler); +``` + +* Virtual callback from node input handler + +You can create your own class derived from HomieNode that implements the virtual method `bool HomieNode::handleInput(const String& property, const String& value)`. The default node input handler then automatically calls your callback. + +```c++ +class RelaisNode : public HomieNode { + public: + RelaisNode(): HomieNode("Relais", "switch8"); + + protected: + virtual bool handleInput(const String& property, const HomieRange& range, const String& value) { + + } +}; +``` + +* Property input handlers. This handler will handle changes for a specific settable property of a specific node + +```c++ +bool propertyInputHandler(const HomieRange& range, const String& value) { + +} + +HomieNode node("id", "type"); + +void setup() { + node.advertise("property").settable(propertyInputHandler); // before Homie.setup() + // ... +} +``` + +You can see that input handlers return a boolean. An input handler can decide whether or not it handled the message and want to propagate it down to other input handlers. If an input handler returns `true`, the propagation is stopped, if it returns `false`, the propagation continues. The order of propagation is global handler → node handler → property handler. + +For example, imagine you defined three input handlers: the global one, the node one, and the property one. If the global input handler returns `false`, the node input handler will be called. If the node input handler returns `true`, the propagation is stopped and the property input handler won't be called. You can think of it as middlewares. + + +!!! warning + Homie uses [ESPAsyncTCP](https://github.com/me-no-dev/ESPAsyncTCP) for network communication that make uses of asynchronous callback from the ESP8266 framework for incoming network packets. Thus the input handler runs in a different task than the `loopHandler()`. So keep in mind that the network task may interrupt your loop at any time. diff --git a/docs/advanced-usage/logging.md b/docs/advanced-usage/logging.md new file mode 100644 index 00000000..e1b48ece --- /dev/null +++ b/docs/advanced-usage/logging.md @@ -0,0 +1,26 @@ +By default, Homie for ESP8266 will output a lot of useful debug messages on the Serial. You may want to disable this behavior if you want to use the Serial line for anything else. + +```c++ +void setup() { + Homie.disableLogging(); // before Homie.setup() + // ... +} +``` + +!!! warning + It's up to you to call `Serial.begin();`, whether logging is enabled or not. + +You can also change the `Print` instance to log to: + +```c++ +void setup() { + Homie.setLoggingPrinter(&Serial2); // before Homie.setup() + // ... +} +``` + +You can use the logger from your code with the `getLogger()` client: + +```c++ +Homie.getLogger() << "Hey!" << endl; +``` diff --git a/docs/advanced-usage/magic-bytes.md b/docs/advanced-usage/magic-bytes.md new file mode 100644 index 00000000..418ac85b --- /dev/null +++ b/docs/advanced-usage/magic-bytes.md @@ -0,0 +1,16 @@ +Homie for ESP8266 firmwares contain magic bytes allowing you to check if a firmware is actually an Homie for ESP8266 firmware, and if so, to get the name, the version and the brand of the firmware. + +You might be wondering why `Homie_setFirmware()` instead of `Homie.setFirmware()`, this is because we use [special macros](https://github.com/marvinroger/homie-esp8266/blob/8935639bc649a6c71ce817ea4f732988506d020e/src/Homie.hpp#L23-L24) to embed the magic bytes. + +Values are encoded as such within the firmware binary: + +Type | Left boundary | Value | Right boundary +---- | ------------- | ----- | -------------- +Homie magic bytes | *None* | `0x25 0x48 0x4F 0x4D 0x49 0x45 0x5F 0x45 0x53 0x50 0x38 0x32 0x36 0x36 0x5F 0x46 0x57 0x25` | *None* +Firmware name | `0xBF 0x84 0xE4 0x13 0x54` | **actual firmware name** | `0x93 0x44 0x6B 0xA7 0x75` +Firmware version | `0x6A 0x3F 0x3E 0x0E 0xE1` | **actual firmware version** | `0xB0 0x30 0x48 0xD4 0x1A` +Firmware brand (only present if `Homie_setBrand()` called, Homie otherwise) | `0xFB 0x2A 0xF5 0x68 0xC0` | **actual firmware brand** | `0x6E 0x2F 0x0F 0xEB 0x2D` + +See the following script for a concrete use case: + +[![GitHub logo](../assets/github.png) firmware_parser.py](https://github.com/marvinroger/homie-esp8266/blob/develop/scripts/firmware_parser) diff --git a/docs/advanced-usage/miscellaneous.md b/docs/advanced-usage/miscellaneous.md new file mode 100644 index 00000000..ae698c92 --- /dev/null +++ b/docs/advanced-usage/miscellaneous.md @@ -0,0 +1,63 @@ +# Know if the device is configured / connected + +If, for some reason, you want to run some code in the Arduino `loop()` function, it might be useful for you to know if the device is in configured (so in `normal` mode) and if the network connection is up. + +```c++ +void loop() { + if (Homie.isConfigured()) { + // The device is configured, in normal mode + if (Homie.isConnected()) { + // The device is connected + } else { + // The device is not connected + } + } else { + // The device is not configured, in either configuration or standalone mode + } +} +``` + +# Get access to the configuration + +You can get access to the configuration of the device. The representation of the configuration is: + +```c++ +struct ConfigStruct { + char* name; + char* deviceId; + + struct WiFi { + char* ssid; + char* password; + } wifi; + + struct MQTT { + struct Server { + char* host; + uint16_t port; + } server; + char* baseTopic; + bool auth; + char* username; + char* password; + } mqtt; + + struct OTA { + bool enabled; + } ota; +}; +``` + +For example, to access the Wi-Fi SSID, you would do: + +```c++ +Homie.getConfiguration().wifi.ssid; +``` + +# Get access to the MQTT client + +You can get access to the underlying MQTT client. For example, to disconnect from the broker: + +```c++ +Homie.getMqttClient().disconnect(); +``` diff --git a/docs/advanced-usage/range-properties.md b/docs/advanced-usage/range-properties.md new file mode 100644 index 00000000..d2a7f5a4 --- /dev/null +++ b/docs/advanced-usage/range-properties.md @@ -0,0 +1,31 @@ +In all the previous examples you have seen, node properties were advertised one-by-one (e.g. `temperature`, `unit`...). But what if you have a LED strip with, say, 100 properties, one for each LED? You won't advertise these 100 LEDs one-by-one. This is what range properties are meant for. + +```c++ +HomieNode stripNode("strip", "strip"); + +bool ledHandler(const HomieRange& range, const String& value) { + Homie.getLogger() << "LED " << range.index << " set to " << value << endl; + + // Now, let's update the actual state of the given led + stripNode.setProperty("led").setRange(range).send(value); +} + +void setup() { + stripNode.advertiseRange("led", 1, 100).settable(ledHandler); + // before Homie.setup() +} +``` + +On the mqtt broker you will see the following message show up: +``` +topic message +-------------------------------------------------------- +homie//strip/$type strip +homie//strip/$properties led[1-100]:settable +``` + +You can then publish the value `on` to topic `homie//strip/led_1/set` to turn on led number 1. + +See the following example for a concrete use case: + +[![GitHub logo](../assets/github.png) LedStrip](https://github.com/marvinroger/homie-esp8266/blob/develop/examples/LedStrip/LedStrip.ino) diff --git a/docs/advanced-usage/resetting.md b/docs/advanced-usage/resetting.md new file mode 100644 index 00000000..d7bdf81b --- /dev/null +++ b/docs/advanced-usage/resetting.md @@ -0,0 +1,35 @@ +Resetting the device means erasing the stored configuration and rebooting from `normal` mode to `configuration` mode. By default, you can do it by pressing for 5 seconds the `FLASH` button of your ESP8266 board. + +This behavior is configurable: + +```c++ +void setup() { + Homie.setResetTrigger(1, LOW, 2000); // before Homie.setup() + // ... +} +``` + +The device will now reset if pin `1` is `LOW` for `2000`ms. You can also disable completely this reset trigger: + +```c++ +void setup() { + Homie.disableResetTrigger(); // before Homie.setup() + // ... +} +``` + +In addition, you can also trigger a device reset from your sketch: + +```c++ +void loop() { + Homie.reset(); +} +``` + +This will reset the device as soon as it is idle. Indeed, sometimes, you might want to disable temporarily the ability to reset the device. For example, if your device is doing some background work like moving shutters, you will want to disable the ability to reset until the shutters are not moving anymore. + +```c++ +Homie.setIdle(false); +``` + +Note that if a reset is asked while the device is not idle, the device will be flagged. In other words, when you will call `Homie.setIdle(true);` back, the device will immediately reset. diff --git a/docs/advanced-usage/standalone-mode.md b/docs/advanced-usage/standalone-mode.md new file mode 100644 index 00000000..8f584025 --- /dev/null +++ b/docs/advanced-usage/standalone-mode.md @@ -0,0 +1,12 @@ +Homie for ESP8266 has a special mode named `standalone`. It was a [requested feature](https://github.com/marvinroger/homie-esp8266/issues/125) to implement a way not to boot into `configuration` mode on initial boot, so that a device can work without being configured first. It was already possible in `configuration` mode, but the device would spawn an AP which would make it insecure. + +To enable this mode, call `Homie.setStandalone()`: + +```c++ +void setup() { + Homie.setStandalone(); // before Homie.setup() + // ... +} +``` + +To actually configure the device, you have to reset it, the same way you would to go from `normal` mode to `configuration` mode. diff --git a/docs/advanced-usage/streaming-operator.md b/docs/advanced-usage/streaming-operator.md new file mode 100644 index 00000000..f420cac6 --- /dev/null +++ b/docs/advanced-usage/streaming-operator.md @@ -0,0 +1,17 @@ +Homie for ESP8266 includes a nice streaming operator to interact with `Print` objects. + +Imagine the following code: + +```c++ +int temperature = 32; +Homie.getLogger().print("The current temperature is "); +Homie.getLogger().print(temperature); +Homie.getLogger().println(" °C."); +``` + +With the streaming operator, the following code will do exactly the same thing, without performance penalties: + +```c++ +int temperature = 32; +Homie.getLogger() << "The current temperature is " << temperature << " °C." << endl; +``` diff --git a/docs/advanced-usage/ui-bundle.md b/docs/advanced-usage/ui-bundle.md new file mode 100644 index 00000000..8e089116 --- /dev/null +++ b/docs/advanced-usage/ui-bundle.md @@ -0,0 +1,3 @@ +The Homie for ESP8266 configuration AP implements a captive portal. When connecting to it, you will be prompted to connect, and your Web browser will open. By default, it will show an empty page with a text saying to install an `ui_bundle.gz` file. + +Indeed, you can serve the [configuration UI](http://marvinroger.github.io/homie-esp8266/configurators/v2/) directly from your ESP8266. See [the data/homie folder](https://github.com/marvinroger/homie-esp8266/tree/develop/data/homie). diff --git a/docs/assets/github.png b/docs/assets/github.png new file mode 100644 index 0000000000000000000000000000000000000000..fd168a46742ae4d95535e8c669f7d270091f35b5 GIT binary patch literal 2944 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 e1C_p%%y}8B*5$n3SD&By7y%q4AuZz9($qy literal 0 HcmV?d00001 diff --git a/docs/assets/led_solid.gif b/docs/assets/led_solid.gif new file mode 100644 index 0000000000000000000000000000000000000000..57aae4eece76e5a6785dcad50870050e9b821c44 GIT binary patch literal 253 zcmZ?wbh9u|6k!ly_`<;O_v43uzkj`JEcwn+B zeObNa$Jvwr|NZ^@>EoZ5&%f>2_O>$jfTcDAaX<&83}hDrYg2--hC`7}MwcAt0+vP1 z0?v$gf5h^zJnG>%c1WQ^qTj7C@u0&54(`qpucdk%OB)xSnk&V&tZBh*Q4zlh95baG z7hHOs^?Plf|8xEwv0c~S|1Pc1t86T5sIJLlPwEg#(~0Vh*A3_m*K>4s^>Fv{@nbYH WF|)L=wzXGMQPa}W(KAqFum%8pE@b%t literal 0 HcmV?d00001 diff --git a/docs/assets/led_wifi.gif b/docs/assets/led_wifi.gif new file mode 100644 index 0000000000000000000000000000000000000000..d7f21605de4fff6e5785144756df59bb152dee87 GIT binary patch literal 324 zcmZ?wbhEHb6k!ly_`<;O_v43uzkj`JEcwn+B zeObNa$Jvwr|NZ^@>EoZ5&%f>2_O>$jfTi~T|Nj|?0E+*){aizWogD*Qjr0td8G%|9 zf3k3lTIF21s=#c1lYfL=o zFoA=+v&3tu9>>zgg{S69u`O#_a9dQwZvw|m>Ba?@UT6JY+voqBe@ATB_4mI^>+>ob z%NnX{^4OC)gwk}PdgFBidc*Y`on1ZLy?p!_jZDleEv#+rl~mNUv~=_g6hY2nMRS(; e1C_p%%y}8B*5$n3SD&By7y%q4Auam?tDT3 literal 0 HcmV?d00001 diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ccfc5e632ae5a1260434d52914df21527574209b GIT binary patch literal 26042 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 literal 0 HcmV?d00001 diff --git a/docs/assets/youtube.png b/docs/assets/youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..ffb6613dea44e0c7c71e039be21e472cecc3cddc GIT binary patch literal 2635 zcmV-R3bgf!P)k*SJXQ4w9;>PcfF_qg@J{*LQ>X$;L@B1eD^ek%R8pi& zloC;riXJ`k)L1YZXhlS)70{sJmy-AWhxua4k zFZNe0ssQ@)`Hv{k%|y5itAEREG%0{5Kze^^FnVmEKeV6%IIym3nVsXx6c}XHQ}9}%3u2ng>ULS8~Lm&p91`~#r#7A`b_-; znyiT8;Yt`@w;M=iT0i3+;8DQqD&`-x#$WxIaBXL?`(d4>t!I4l+(O|y0A0Pj(M(nt ze9b^H|LvL9opuP=pU+>cOn>23aFWO@oB*Q4+fV&qc-kI-n7(I?-*^~cPSg8lc>cJ7 zVsSTsi!)1QGDS?g_viB`weLvk5RfGp%UKMJ7SPb&h)5b?DIewsQ1c z6+H*gdqrf_b*2@Gw7^P~cb#Lo^hMEdsT|$ux3r$C&>x6m!gZ#VM1ed5=yDxoxotgi z2hOU1xHMfG$>`>G}a& zST6s&w;X=Lr?gjq$6e=fg@8_0)utpge=yM-hTm@|Iv0w&02y(escx8k=6QyRF_X?Omn)?(ygRO?=cwX7h>W|=^mYm0 zS>rF#j!LE68-}+jdmEwnF=iQhWi?=ctN@m%w_JX`6oy|)IoYg=U&#s|5p*_I0B_Uw zw?_Wk8-}0KRJKC&_pb9SmK8vqY#$o>dnt-OCM1^#5V_8?Pz_T6u7|ZzfcF=>Kh45F zXJFM_Vz=R5>)^RU;p+W_dl=ZV+D2Eh0(ct+3Wdv3m3x`6-F23wvI1Cw{(OFy0(+Ej zDX;Q>q^tlIVW6jLtEAo@5H4S3d-_>f0W84ZipBF1=sl_68m85Dl%Jawz#R5>cW)q% z?nuBN#}!l?PgJOQ97`vpF=VMA_h zrE+66O>bvHL);^|7$Q{l6*5jub_93|@Yk-(e@;h-?f#jE)X<8*R7!PLYEdqCLTY@cx!Vc1t-xRJJu{+{j&HRU}-xWaXo6^}9G z6KT{4Qv``nvA*K0J18`NWl^ zdVltjOqNpe9yoFNqyp%TqP-CPQ?`*zR#frW_QAoE&iS0ygNl5)6*i?xBZ>*Q?bIhv zD}Zfp4IhBw?rbudtSI!3QW*a8)aPgH52^s|FXn&2pzD@DnpuAGvr>8J^E0iPu@fx; z+FOo3tBMDgH<(#&s(5f)dFY1O*3a6DngHny!<(SEzM+OlZx$j|h2Gd3hBwh1;~3A| zjlL9yzf8&Btcsrii0c~CEDHf1hv&bqw;cUwzSl15o{)V#J!b}zTo1uD!c=lSJgYf@ z$e0rUR)Gil!f?S?cbD9N&vkUH^K$+U6~0hN&ts*tK{#80v#c(HS^PhwH`hI(luTF1jek9g@ih@VeYyFH*wfDQsWLD~+W9ZGEg+CZ{K zmDiAJPMC5mniHm07HtJnt4w}xVUbo$&Tq6@rnpH&WP+Ki5SajRRS4rOIu53)LdGF< z6hvbni~)L-fQka63LYh3Wa?`;AsPwdIAUPvy~D%9a~;zcYt?P33h>h#Hnf~MIvS*L z97rw~Xp#h)rU8N=5D_1q1ggF-BEF~Usi(fG=d-G(svdjliF%%@dIXRH%0!|_g$bf5 z2@$12loF+hq?CdvVWN~NN#cYO^1PUs6U1>WzF(tiHBMSuYU7=qwM!2ktktuTj4{R- tV~jDz7-Nhv#u#IaF~%5Uj4@_m{twuW*r<{bA^-pY002ovPDHLkV1fbV)0_YR literal 0 HcmV?d00001 diff --git a/docs/configuration/http-json-api.md b/docs/configuration/http-json-api.md new file mode 100644 index 00000000..c18f66e3 --- /dev/null +++ b/docs/configuration/http-json-api.md @@ -0,0 +1,264 @@ +When in `configuration` mode, the device exposes a HTTP JSON API to send the configuration to it. When you send a valid configuration to the `/config` endpoint, the configuration file is stored in the filesystem at `/homie/config.json`. + +If you don't want to mess with JSON, you have a Web UI / app available: + +* At http://marvinroger.github.io/homie-esp8266/configurators/v2/ +* As an [Android app](https://build.phonegap.com/apps/1906578/share) + +**Quick instructions to use the Web UI / app**: + +1. Open the Web UI / app +2. Disconnect from your current Wi-Fi AP, and connect to the `Homie-xxxxxxxxxxxx` AP spawned in `configuration` mode +3. Follow the instructions + +You can see the sources of the Web UI [here](https://github.com/marvinroger/homie-esp8266-setup). + +Alternatively, you can use this `curl` command to send the configuration to the device. You must connect to the device in `configuration` mode (i.e. the device is an Access Point). This method will not work if not in `configuration` mode: + +```shell +curl -X PUT http://192.168.123.1/config --header "Content-Type: application/json" -d @config.json +``` + +This will send the `./config.json` file to the device. + +# Error handling + +When everything went fine, a `2xx` HTTP code is returned, such as `200 OK`, `202 Accepted`, `204 No Content` and so on. +If anything goes wrong, a return code != 2xx will be returned, with a JSON `error` field indicating the error, such as `500 Internal Server error`, `400 Bad request` and so on. + +# Endpoints + +**API base address:** `http://192.168.123.1` + +??? summary "GET `/heart`" + This is useful to ensure we are connected to the device AP. + + ## Response + + `204 No Content` + +-------------- + +??? summary "GET `/device-info`" + Get some information on the device. + + ## Response + + `200 OK (application/json)` + + ```json + { + "hardware_device_id": "52a8fa5d", + "homie_esp8266_version": "2.0.0", + "firmware": { + "name": "awesome-device", + "version": "1.0.0" + }, + "nodes": [ + { + "id": "light", + "type": "light" + } + ], + "settings": [ + { + "name": "timeout", + "description": "Timeout in seconds", + "type": "ulong", + "required": false, + "default": 10 + } + ] + } + ``` + + `type` can be one of the following: + + * `bool`: a boolean + * `ulong`: an unsigned long + * `long`: a long + * `double`: a double + * `string`: a string + + !!! note "Note about settings" + If a setting is not required, the `default` field will always be set. + +-------------- + +??? summary "GET `/networks`" + Retrieve the Wi-Fi networks the device can see. + + ## Response + + !!! success "In case of success" + `200 OK (application/json)` + + ```json + { + "networks": [ + { "ssid": "Network_2", "rssi": -82, "encryption": "wep" }, + { "ssid": "Network_1", "rssi": -57, "encryption": "wpa" }, + { "ssid": "Network_3", "rssi": -65, "encryption": "wpa2" }, + { "ssid": "Network_5", "rssi": -94, "encryption": "none" }, + { "ssid": "Network_4", "rssi": -89, "encryption": "auto" } + ] + } + ``` + + !!! failure "In case the initial Wi-Fi scan is not finished on the device" + `503 Service Unavailable (application/json)` + + ```json + { + "error": "Initial Wi-Fi scan not finished yet" + } + ``` + +-------------- + +??? summary "PUT `/config`" + Save the config to the device. + + ## Request body + + `(application/json)` + + See [JSON configuration file](json-configuration-file.md). + + ## Response + + !!! success "In case of success" + `200 OK (application/json)` + + ```json + { + "success": true + } + ``` + + !!! failure "In case of error in the payload" + `400 Bad Request (application/json)` + + ```json + { + "success": false, + "error": "Reason why the payload is invalid" + } + ``` + + !!! failure "In case the device already received a valid configuration and is waiting for reboot" + `403 Forbidden (application/json)` + + ```json + { + "success": false, + "error": "Device already configured" + } + ``` + +-------------- + +??? summary "PUT `/wifi/connect`" + Initiates the connection of the device to the Wi-Fi network while in configuation mode. This request is not synchronous and the result (Wi-Fi connected or not) must be obtained by with `GET /wifi/status`. + + ## Request body + + `(application/json)` + + ```json + { + "ssid": "My_SSID", + "password": "my-passw0rd" + } + ``` + + ## Response + + !!! success "In case of success" + `202 Accepted (application/json)` + + ```json + { + "success": true + } + ``` + + !!! failure "In case of error in the payload" + `400 Bad Request (application/json)` + + ```json + { + "success": false, + "error": "Reason why the payload is invalid" + } + ``` + +-------------- + +??? summary "GET `/wifi/status`" + Returns the current Wi-Fi connection status. + + Helpful when monitoring Wi-Fi connectivity after `PUT /wifi/connect`. + + ## Response + + `200 OK (application/json)` + + ```json + { + "status": "connected" + } + ``` + + `status` might be one of the following: + + * `idle` + * `connect_failed` + * `connection_lost` + * `no_ssid_available` + * `connected` along with a `local_ip` field + * `disconnected` + +-------------- + +??? summary "PUT `/proxy/control`" + Enable/disable the device to act as a transparent proxy between AP and Station networks. + + All requests that don't collide with existing API paths will be bridged to the destination according to the `Host` HTTP header. The destination host is called using the existing Wi-Fi connection (established after a `PUT /wifi/connect`) and all contents are bridged back to the connection made to the AP side. + + This feature can be used to help captive portals to perform cloud API calls during device enrollment using the ESP8266 Wi-Fi AP connection without having to patch the Homie firmware. By using the transparent proxy, all operations can be performed by the custom JavaScript running on the browser (in SPIFFS location `/data/homie/ui_bundle.gz`). + + HTTPS is not supported. + + **Important**: The HTTP requests and responses must be kept as small as possible because all contents are transported using RAM memory, which is very limited. + + ## Request body + + `(application/json)` + + ```json + { + "enable": true + } + ``` + + ## Response + + ??? success "In case of success" + `200 OK (application/json)` + + ```json + { + "success": true + } + ``` + + ??? failure "In case of error in the payload" + `400 Bad Request (application/json)` + + ```json + { + "success": false, + "error": "Reason why the payload is invalid" + } + ``` diff --git a/docs/configuration/json-configuration-file.md b/docs/configuration/json-configuration-file.md new file mode 100644 index 00000000..2924eef8 --- /dev/null +++ b/docs/configuration/json-configuration-file.md @@ -0,0 +1,59 @@ +To configure your device, you have two choices: manually flashing the configuration file to the SPIFFS at the `/homie/config.json` (see [Uploading files to file system](http://esp8266.github.io/Arduino/versions/2.3.0/doc/filesystem.html#uploading-files-to-file-system)), so you can bypass the `configuration` mode, or send it through the [HTTP JSON API](http-json-api.md). + +Below is the format of the JSON configuration you will have to provide: + +```json +{ + "name": "The kitchen light", + "device_id": "kitchen-light", + "device_stats_interval": 60, + "wifi": { + "ssid": "Network_1", + "password": "I'm a Wi-Fi password!", + "bssid": "DE:AD:BE:EF:BA:BE", + "channel": 1, + "ip": "192.168.1.5", + "mask": "255.255.255.0", + "gw": "192.168.1.1", + "dns1": "8.8.8.8", + "dns2": "8.8.4.4" + }, + "mqtt": { + "host": "192.168.1.10", + "port": 1883, + "base_topic": "devices/", + "auth": true, + "username": "user", + "password": "pass" + }, + "ota": { + "enabled": true + }, + "settings": { + "percentage": 55 + } +} +``` + +The above JSON contains every field that can be customized. + +Here are the rules: + +* `name`, `wifi.ssid`, `wifi.password`, `mqtt.host` and `ota.enabled` are mandatory +* `wifi.password` can be `null` if connecting to an open network +* If `mqtt.auth` is `true`, `mqtt.username` and `mqtt.password` must be provided +* `bssid`, `channel`, `ip`, `mask`, `gw`, `dns1`, `dns2` are not mandatory and are only needed to if there is a requirement to specify particular AP or set Static IP address. There are some rules which needs to be satisfied: + - `bssid` and `channel` have to be defined together and these settings are independand of settings related to static IP + - to define static IP, `ip` (IP address), `mask` (netmask) and `gw` (gateway) settings have to be defined at the same time + - to define second DNS `dns2` the first one `dns1` has to be defined. Set DNS without `ip`, `mask` and `gw` does not affect the configuration (dns server will be provided by DHCP). It is not required to set DNS servers. + + +Default values if not provided: + +* `device_id`: the hardware device ID (eg. `1a2b3c4d5e6f`) +* `device_stats_interval`: 60 seconds +* `mqtt.port`: `1883` +* `mqtt.base_topic`: `homie/` +* `mqtt.auth`: `false` + +The `mqtt.host` field can be either an IP or an hostname. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..74a4a83e --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +Welcome on the Homie for ESP8266 docs. diff --git a/docs/others/community-projects.md b/docs/others/community-projects.md new file mode 100644 index 00000000..faceb1a5 --- /dev/null +++ b/docs/others/community-projects.md @@ -0,0 +1,19 @@ +This page lists the projects made by the community to work with Homie. + +# [jpmens/homie-ota](https://github.com/jpmens/homie-ota) + +homie-ota is written in Python. It provides an OTA server for Homie devices as well as a simple inventory which can be useful to keep track of Homie devices. homie-ota also enables you to trigger an OTA update (over MQTT, using the Homie convention) from within its inventory. New firmware can be uploaded to homie-ota which detects firmware name (fwname) and version (fwversion) from the uploaded binary blob, thanks to an idea and code contributed by Marvin. + +# [stufisher/homie-control](https://github.com/stufisher/homie-control) + +homie-control provides a web UI to manage Homie devices as well as a series of virtual python devices to allow extended functionality. + +Its lets you do useful things like: + +* Historically log device properties +* Schedule changes in event properties (i.e. water your garden once a day) +* Execute profiles of property values (i.e. turn a series of lights on and off simultaneously) +* Trigger property changes based on: + * When a network device is dis/connected (i.e. your phone joins your wifi, turn the lights on) + * Sunset / rise + * When another property changes diff --git a/docs/others/cpp-api-reference.md b/docs/others/cpp-api-reference.md new file mode 100644 index 00000000..1cabf21c --- /dev/null +++ b/docs/others/cpp-api-reference.md @@ -0,0 +1,323 @@ +# Homie + +You don't have to instantiate an `Homie` instance, it is done internally. + +```c++ +void setup(); +``` + +Setup Homie. + +!!! warning "Mandatory!" + Must be called once in `setup()`. + +```c++ +void loop(); +``` + +Handle Homie work. + +!!! warning "Mandatory!" + Must be called once in `loop()`. + +## Functions to call *before* `Homie.setup()` + +```c++ +void Homie_setFirmware(const char* name, const char* version); +// This is not a typo +``` + +Set the name and version of the firmware. This is useful for OTA, as Homie will check against the server if there is a newer version. + +!!! warning "Mandatory!" + You need to set the firmware for your sketch to work. + + +* **`name`**: Name of the firmware. Default value is `undefined` +* **`version`**: Version of the firmware. Default value is `undefined` + +```c++ +void Homie_setBrand(const char* name); +// This is not a typo +``` + +Set the brand of the device, used in the configuration AP, the device hostname and the MQTT client ID. + +* **`name`**: Name of the brand. Default value is `Homie` + +```c++ +Homie& disableLogging(); +``` + +Disable Homie logging. + +```c++ +Homie& setLoggingPrinter(Print* printer); +``` + +Set the Print instance used for logging. + +* **`printer`**: Print instance to log to. By default, `Serial` is used + +!!! warning + It's up to you to call `Serial.begin()` + +```c++ +Homie& disableLedFeedback(); +``` + +Disable the built-in LED feedback indicating the Homie for ESP8266 state. + +```c++ +Homie& setLedPin(uint8_t pin, uint8_t on); +``` + +Set pin of the LED to control. + +* **`pin`**: LED to control +* **`on`**: state when the light is on (HIGH or LOW) + +```c++ +Homie& setConfigurationApPassword(const char* password); +``` + +Set the configuration AP password. + +* **`password`**: the configuration AP password + +```c++ +Homie& setGlobalInputHandler(std::function handler); +``` + +Set input handler for subscribed properties. + +* **`handler`**: Global input handler +* **`node`**: Name of the node getting updated +* **`property`**: Property of the node getting updated +* **`range`**: Range of the property of the node getting updated +* **`value`**: Value of the new property + +```c++ +Homie& setBroadcastHandler(std::function handler); +``` + +Set broadcast handler. + +* **`handler`**: Broadcast handler +* **`level`**: Level of the broadcast +* **`value`**: Value of the broadcast + +```c++ +Homie& onEvent(std::function callback); +``` + +Set the event handler. Useful if you want to hook to Homie events. + +* **`callback`**: Event handler + +```c++ +Homie& setResetTrigger(uint8_t pin, uint8_t state, uint16_t time); +``` + +Set the reset trigger. By default, the device will reset when pin `0` is `LOW` for `5000`ms. + +* **`pin`**: Pin of the reset trigger +* **`state`**: Reset when the pin reaches this state for the given time +* **`time`**: Time necessary to reset + +```c++ +Homie& disableResetTrigger(); +``` + +Disable the reset trigger. + +```c++ +Homie& setSetupFunction(std::function callback); +``` + +You can provide the function that will be called when operating in `normal` mode. + +* **`callback`**: Setup function + +```c++ +Homie& setLoopFunction(std::function callback); +``` + +You can provide the function that will be looped in normal mode. + +* **`callback`**: Loop function + +```c++ +Homie& setStandalone(); +``` + +This will mark the Homie firmware as standalone, meaning it will first boot in `standalone` mode. To configure it and boot to `configuration` mode, the device has to be resetted. + +## Functions to call *after* `Homie.setup()` + +```c++ +void reset(); +``` + +Flag the device for reset. + +```c++ +void setIdle(bool idle); +``` + +Set the device as idle or not. This is useful at runtime, because you might want the device not to be resettable when you have another library that is doing some unfinished work, like moving shutters for example. + +* **`idle`**: Device in an idle state or not + +```c++ +void prepareToSleep(); +``` + +Prepare the device for deep sleep. It ensures messages are sent and disconnects cleanly from the MQTT broker, triggering a `READY_TO_SLEEP` event when done. + +```c++ +void doDeepSleep(uint32_t time_us = 0, RFMode mode = RF_DEFAULT); +``` + +Puth the device into deep sleep. It ensures the Serial is flushed. + +```c++ +bool isConfigured() const; +``` + +Is the device in `normal` mode, configured? + +```c++ +bool isConnected() const; +``` + +Is the device in `normal` mode, configured and connected? + +```c++ +const ConfigStruct& getConfiguration() const; +``` + +Get the configuration struct. + +!!! danger + Be careful with this struct, never attempt to change it. + +```c++ +AsyncMqttClient& getMqttClient(); +``` + +Get the underlying `AsyncMqttClient` object. + +```c++ +Logger& getLogger(); +``` + +Get the underlying `Logger` object, which is only a wrapper around `Serial` by default. + +------- + +# HomieNode + +```c++ +HomieNode(const char* id, const char* type, std::function handler = ); +``` + +Constructor of an HomieNode object. + +* **`id`**: ID of the node +* **`type`**: Type of the node +* **`handler`**: Optional. Input handler of the node + +```c++ +const char* getId() const; +``` + +Return the ID of the node. + +```c++ +const char* getType() const; +``` + +Return the type of the node. + +```c++ +PropertyInterface& advertise(const char* property); +PropertyInterface& advertiseRange(const char* property, uint16_t lower, uint16_t upper); +``` + +Advertise a property / range property on the node. + +* **`property`**: Property to advertise +* **`lower`**: Lower bound of the range +* **`upper`**: Upper bound of the range + +This returns a reference to `PropertyInterface` on which you can call: + +```c++ +void settable(std::function handler) = ); +``` + +Make the property settable. + +* **`handler`**: Optional. Input handler of the property + +```c++ +SendingPromise& setProperty(const String& property); +``` + +Using this function, you can set the value of a node property, like a temperature for example. + +* **`property`**: Property to send + +This returns a reference to `SendingPromise`, on which you can call: + +```c++ +SendingPromise& setQos(uint8_t qos); // defaults to 1 +SendingPromise& setRetained(bool retained); // defaults to true +SendingPromise& overwriteSetter(bool overwrite); // defaults to false +SendingPromise& setRange(const HomieRange& range); // defaults to not a range +SendingPromise& setRange(uint16_t rangeIndex); // defaults to not a range +uint16_t send(const String& value); // finally send the property, return the packetId (or 0 if failure) +``` + +Method names should be self-explanatory. + +# HomieSetting + +```c++ +HomieSetting(const char* name, const char* description); +``` + +Constructor of an HomieSetting object. + +* **`T`**: Type of the setting. Either `bool`, `unsigned long`, `long`, `double` or `const char*` +* **`name`**: Name of the setting +* **`description`**: Description of the setting + +```c++ +T get() const; +``` + +Get the default value if the setting is optional and not provided, or the provided value if the setting is required or optional but provided. + +```c++ +bool wasProvided() const; +``` + +Return whether the setting was provided or not (otherwise `get()` would return the default value). + +Set the default value and make the setting optional. + +```c++ +HomieSetting& setDefaultValue(T defaultValue); +``` + +* **`defaultValue`**: The default value + +```c++ +HomieSetting& setValidator(std::function validator); +``` + +Set a validation function for the setting. The validator must return `true` if the candidate is correct, `false` otherwise. + +* **`validator`**: The validation function diff --git a/docs/others/homie-implementation-specifics.md b/docs/others/homie-implementation-specifics.md new file mode 100644 index 00000000..0f1fbc67 --- /dev/null +++ b/docs/others/homie-implementation-specifics.md @@ -0,0 +1,30 @@ +The Homie `$implementation` identifier is `esp8266`. + +# Version + +* `$implementation/version`: Homie for ESP8266 version + +# Reset + +* `$implementation/reset`: You can publish a `true` to this topic to reset the device + +# Configuration + +* `$implementation/config`: The `configuration.json` is published there, with `wifi.password`, `mqtt.username` and `mqtt.password` fields stripped +* `$implementation/config/set`: You can update the `configuration.json` by sending incremental JSON on this topic + +# OTA + +* `$implementation/ota/enabled`: `true` if OTA is enabled, `false` otherwise +* `$implementation/ota/firmware`: If the update request is accepted, you must send the firmware payload to this topic +* `$implementation/ota/status`: HTTP-like status code indicating the status of the OTA. Might be: + +Code|Description +----|----------- +`200`|OTA successfully flashed +`202`|OTA request / checksum accepted +`206 465/349680`|OTA in progress. The data after the status code corresponds to `/` +`304`|The current firmware is already up-to-date +`400 BAD_FIRMWARE`|OTA error from your side. The identifier might be `BAD_FIRMWARE`, `BAD_CHECKSUM`, `NOT_ENOUGH_SPACE`, `NOT_REQUESTED` +`403`|OTA not enabled +`500 FLASH_ERROR`|OTA error on the ESP8266. The identifier might be `FLASH_ERROR` diff --git a/docs/others/limitations-and-known-issues.md b/docs/others/limitations-and-known-issues.md new file mode 100644 index 00000000..4849a012 --- /dev/null +++ b/docs/others/limitations-and-known-issues.md @@ -0,0 +1,11 @@ +# SSL support + +In Homie for ESP8266 v1.x, SSL was possible but it was not reliable. Due to the asynchronous nature of the v2.x, SSL is not available anymore. + +# ADC readings + +[This is a known esp8266/Arduino issue](https://github.com/esp8266/Arduino/issues/1634) that polling `analogRead()` too frequently forces the Wi-Fi to disconnect. As a workaround, don't poll the ADC more than one time every 3ms. + +# Wi-Fi connection + +If you encouter any issues with the Wi-Fi, try changing the flash size build parameter, or try to erase the flash. See [#158](https://github.com/marvinroger/homie-esp8266/issues/158) for more information. diff --git a/docs/others/ota-configuration-updates.md b/docs/others/ota-configuration-updates.md new file mode 100644 index 00000000..100ea920 --- /dev/null +++ b/docs/others/ota-configuration-updates.md @@ -0,0 +1,61 @@ +# OTA updates + +Homie for ESP8266 supports OTA, if enabled in the configuration, and if a compatible OTA entity is set up. + +There's a script that does just that: + +[![GitHub logo](../assets/github.png) ota_updater.py](https://github.com/marvinroger/homie-esp8266/blob/develop/scripts/ota_updater) + +It works this way: + +1. During startup of the Homie for ESP8266 device, it reports the current firmware's MD5 to `$fw/checksum` (in addition to `$fw/name` and `$fw/version`). The OTA entity may or may not use this information to automatically schedule OTA updates +2. The OTA entity publishes the latest available firmware payload to `$implementation/ota/firmware/`, either as binary or as a Base64 encoded string + * If OTA is disabled, Homie for ESP8266 reports `403` to `$implementation/ota/status` and aborts the OTA + * If OTA is enabled and the latest available checksum is the same as what is currently running, Homie for ESP8266 reports `304` and aborts the OTA + * If the checksum is not a valid MD5, Homie for ESP8266 reports `400 BAD_CHECKSUM` to `$implementation/ota/status` and aborts the OTA +3. Homie starts to flash the firmware + * The firmware is updating. Homie for ESP8266 reports progress with `206 /` + * When all bytes are flashed, the firmware is verified (including the MD5 if one was set) + * Homie for ESP8266 either reports `200` on success, `400` if the firmware in invalid or `500` if there's an internal error +5. Homie for ESP8266 reboots on success as soon as the device is idle + +See [Homie implementation specifics](homie-implementation-specifics.md) for more details on status codes. + +## OTA entities projects + +See [Community projects](community-projects.md). + +# Configuration updates + +In `normal` mode, you can get the current `config.json`, published on `$implementation/config` with `wifi.password`, `mqtt.username` and `mqtt.password` stripped. You can update the configuration on-the-fly by publishing incremental JSON updates to `$implementation/config/set`. For example, given the following `config.json`: + +```json +{ + "name": "Kitchen light", + "wifi": { + "ssid": "Network_1", + "password": "I'm a Wi-Fi password!" + }, + "mqtt": { + "host": "192.168.1.20", + "port": 1883 + }, + "ota": { + "enabled": false + }, + "settings": { + + } +} +``` + +You can update the name and Wi-Fi password by sending the following incremental JSON: + +```json +{ + "name": "Living room light", + "wifi": { + "password": "I'am a new Wi-Fi password!" + } +} +``` diff --git a/docs/others/troubleshooting.md b/docs/others/troubleshooting.md new file mode 100644 index 00000000..0dc71b79 --- /dev/null +++ b/docs/others/troubleshooting.md @@ -0,0 +1,37 @@ +## 1. I see some garbage on the Serial monitor? + +You are probably using a generic ESP8266. The problem with these modules is the built-in LED is tied to the serial line. You can do two things: + +* Disable the serial logging, to have the LED working: + +```c++ +void setup() { + Homie.enableLogging(false); // before Homie.setup() + // ... +} +``` + +* Disable the LED blinking, to have the serial line working: + +```c++ +void setup() { + Homie.enableBuiltInLedIndicator(false); // before Homie.setup() + // ... +} +``` + +## 2. I see an `abort` message on the Serial monitor? + +`abort()` is called by Homie for ESP8266 when the framework is used in a bad way. The possible causes are: + +* You are calling a function that is meant to be called before `Homie.setup()`, after `Homie.setup()` + +* One of the string you've used (in `setFirmware()`, `subscribe()`, etc.) is too long. Check the `Limits.hpp` file to see the max length possible for each string. + +## 3. The network is completely unstable... What's going on? + +The framework needs to work continuously (ie. `Homie.loop()` needs to be called very frequently). In other words, don't use `delay()` (see [avoid delay](http://playground.arduino.cc/Code/AvoidDelay)) or anything that might block the code for more than 50ms or so. There is also a known Arduino for ESP8266 issue with `analogRead()`, see [Limitations and known issues](limitations-and-known-issues.md#adc-readings). + +## 4. My device resets itself without me doing anything? + +You have probably connected a sensor to the default reset pin of the framework (D3 on NodeMCU, GPIO0 on other boards). See [Resetting](../advanced-usage/resetting.md). diff --git a/docs/others/upgrade-guide-from-v1-to-v2.md b/docs/others/upgrade-guide-from-v1-to-v2.md new file mode 100644 index 00000000..e7a69526 --- /dev/null +++ b/docs/others/upgrade-guide-from-v1-to-v2.md @@ -0,0 +1,15 @@ +This is an upgrade guide to upgrade your Homie devices from v1 to v2. + +## New convention + +The Homie convention has been revised to v2 to be more extensible and introspectable. Be sure to [check it out](https://github.com/marvinroger/homie/tree/v2). + +## API changes in the sketch + +1. `Homie.setFirmware(name, version)` must be replaced by `Homie_setFirmware(name, version)` +2. `Homie.setBrand(brand)` must be replaced by `Homie_setBrand(brand)` +3. `Homie.registerNode()` must be removed, nodes are now automagically registered +4. If you've enabled Serial logging, `Serial.begin()` must be called explicitely in your sketch +5. Remove the `HOMIE_OTA_MODE` in your event handler, if you have one +6. The `Homie.setNodeProperty()` signature changed completely. If you had `Homie.setNodeProperty(node, "property", "value", true)`, the new equivalent syntax is `Homie.setNodeProperty(node, "property").setRetained(true).send("value")`. Note the `setRetained()` is not even required as messages are retained by default. +7. TODO diff --git a/docs/quickstart/getting-started.md b/docs/quickstart/getting-started.md new file mode 100644 index 00000000..709ef514 --- /dev/null +++ b/docs/quickstart/getting-started.md @@ -0,0 +1,142 @@ +This *Getting Started* guide assumes you have an ESP8266 board with an user-configurable LED, and an user programmable button, like a NodeMCU DevKit 1.0, for example. These restrictions can be lifted (see next pages). + +To use Homie for ESP8266, you will need: + +* An ESP8266 +* The Arduino IDE for ESP8266 (version 2.3.0 minimum) +* Basic knowledge of the Arduino environment (upload a sketch, import libraries, ...) +* To understand [the Homie convention](https://github.com/marvinroger/homie) + +## Installing Homie for ESP8266 + +There are two ways to install Homie for ESP8266. + +### 1a. For the Arduino IDE + +There is a YouTube video with instructions: + +[![YouTube logo](../assets/youtube.png) How to install Homie libraries on Arduino IDE](https://www.youtube.com/watch?v=bH3KfFfYUvg) + +1. Download the [release corresponding to this documentation version](https://github.com/marvinroger/homie-esp8266/releases) + +2. Load the `.zip` with **Sketch → Include Library → Add .ZIP Library** + +Homie for ESP8266 has 5 dependencies: + +* [ArduinoJson](https://github.com/bblanchon/ArduinoJson) >= 5.0.8 +* [Bounce2](https://github.com/thomasfredericks/Bounce2) +* [ESPAsyncTCP](https://github.com/me-no-dev/ESPAsyncTCP) >= [c8ed544](https://github.com/me-no-dev/ESPAsyncTCP) +* [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) +* [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) + +Some of them are available through the Arduino IDE, with **Sketch → Include Library → Manage Libraries**. For the others, install it by downloading the `.zip` on GitHub. + +### 1b. With [PlatformIO](http://platformio.org) + +In a terminal, run `platformio lib install 555`. + +!!! warning "Not yet released as stable" + The above command is for when the v2 is stable and released. Currently, the latest stable version is 1.5. In the meantime, use the develop branch to get started with the v2, add this in your **platformio.ini**: + + ``` + lib_deps = git+https://github.com/marvinroger/homie-esp8266.git#develop + ``` + +Dependencies are installed automatically. + +## Bare minimum sketch + +```c++ +#include + +void setup() { + Serial.begin(115200); + Serial << endl << endl; + + Homie_setFirmware("bare-minimum", "1.0.0"); // The underscore is not a typo! See Magic bytes + Homie.setup(); +} + +void loop() { + Homie.loop(); +} +``` + + +This is the bare minimum needed for Homie for ESP8266 to work correctly. + +!!! tip "LED" + ![Solid LED](../assets/led_solid.gif) + If you upload this sketch, you will notice the LED of the ESP8266 will light on. This is because you are in `configuration` mode. + +Homie for ESP8266 has 3 modes of operation: + +1. By default, the `configuration` mode is the initial one. It spawns an AP and an HTTP webserver exposing a JSON API. To interact with it, you have to connect to the AP. Then, an HTTP client can get the list of available Wi-Fi networks and send the configuration (like the Wi-Fi SSID, the Wi-Fi password, some settings...). Once the device receives the credentials, it boots into `normal` mode. + +2. The `normal` mode is the mode the device will be most of the time. It connects to the Wi-Fi, to the MQTT, it sends initial informations to the Homie server (like the local IP, the version of the firmware currently running...) and it subscribes to the needed MQTT topics. It automatically reconnects to the Wi-Fi and the MQTT when the connection is lost. It also handle the OTA. The device can return to `configuration` mode in different ways (press of a button or custom function, see [Resetting](../advanced-usage/resetting.md)). + +3. The `standalone` mode. See [Standalone mode](../advanced-usage/standalone-mode.md). + +!!! warning + **As a rule of thumb, never block the device with blocking code for more than 50ms or so.** Otherwise, you may very probably experience unexpected behaviors. + +## Connecting to the AP and configuring the device + +Homie for ESP8266 has spawned a secure AP named `Homie-xxxxxxxxxxxx`, like `Homie-c631f278df44`. Connect to it. + +!!! tip "Hardware device ID" + This `c631f278df44` ID is unique to each device, and you cannot change it (this is actually the MAC address of the station mode). If you flash a new sketch, this ID won't change. + +Once connected, the webserver is available at `http://192.168.123.1`. Every domain name is resolved by the built-in DNS server to this address. You can then configure the device using the [HTTP JSON API](../configuration/http-json-api.md). When the device receives its configuration, it will reboot into `normal` mode. + +## Understanding what happens in `normal` mode + +### Visual codes + +When the device boots in `normal` mode, it will start blinking: + +!!! tip "LED" + ![Slowly blinking LED](../assets/led_wifi.gif) + Slowly when connecting to the Wi-Fi + +!!! tip "LED" + ![Fast blinking LED](../assets/led_mqtt.gif) + Faster when connecting to the MQTT broker + +This way, you can have a quick feedback on what's going on. If both connections are established, the LED will stay off. Note the device will also blink during the automatic reconnection, if the connection to the Wi-Fi or the MQTT broker is lost. + +### Under the hood + +Although the sketch looks like it does not do anything, it actually does quite a lot: + +* It automatically connects to the Wi-Fi and MQTT broker. No more network boilerplate code +* It exposes the Homie device on MQTT (as `/`, e.g. `homie/c631f278df44`) +* It subscribes to the special OTA and configuration topics, automatically flashing a sketch if available or updating the configuration +* It checks for a button press on the ESP8266, to return to `configuration` mode + +## Creating an useful sketch + +Now that we understand how Homie for ESP8266 works, let's create an useful sketch. We want to create a smart light. + +[![GitHub logo](../assets/github.png) LightOnOff.ino](https://github.com/marvinroger/homie-esp8266/blob/develop/examples/LightOnOff/LightOnOff.ino) + +Alright, step by step: + +1. We create a node with an ID of `light` and a type of `switch` with `HomieNode lightNode("light", "switch")` +2. We set the name and the version of the firmware with `Homie_setFirmware("awesome-light" ,"1.0.0");` +3. We want our `light` node to advertise an `on` property, which is settable. We do that with `lightNode.advertise("on").settable(lightOnHandler);`. The `lightOnHandler` function will be called when the value of this property is changed +4. In the `lightOnHandler` function, we want to update the state of the `light` node. We do this with `lightNode.setProperty("on").send("true");` + +In about thirty SLOC, we have achieved to create a smart light, without any hard-coded credentials, with automatic reconnection in case of network failure, and with OTA support. Not bad, right? + +## Creating a sensor node + +In the previous example sketch, we were reacting to property changes. But what if we want, for example, to send a temperature every 5 minutes? We could do this in the Arduino `loop()` function. But then, we would have to check if we are in `normal` mode, and we would have to ensure the network connection is up before being able to send anything. Boring. + +Fortunately, Homie for ESP8266 provides an easy way to do that. + +[![GitHub logo](../assets/github.png) TemperatureSensor.ino](https://github.com/marvinroger/homie-esp8266/blob/develop/examples/TemperatureSensor/TemperatureSensor.ino) + +The only new things here are the `Homie.setSetupFunction(setupHandler);` and `Homie.setLoopFunction(loopHandler);` calls. The setup function will be called once, when the device is in `normal` mode and the network connection is up. The loop function will be called everytime, when the device is in `normal` mode and the network connection is up. This provides a nice level of abstraction. + +Now that you understand the basic usage of Homie for ESP8266, you can head on to the next pages to learn about more powerful features like input handlers, the event system and custom settings. diff --git a/docs/quickstart/what-is-it.md b/docs/quickstart/what-is-it.md new file mode 100644 index 00000000..87303ee2 --- /dev/null +++ b/docs/quickstart/what-is-it.md @@ -0,0 +1,3 @@ +Homie for ESP8266 is an ESP8266 for Arduino implementation of [Homie](https://github.com/marvinroger/homie), a thin and simple MQTT convention for the IoT. More than that, it's also a full-featured framework to get started with your IoT project very quickly. Simply put, you don't have to manage yourself the connection/reconnection to the Wi-Fi/MQTT. You don't even have to hard-code credentials in your sketch: this can be done using a simple JSON API. Everything is handled internally, by Homie for ESP8266. + +You guessed it, the purpose of Homie for ESP8266 is to simplify the development of connected objects. diff --git a/examples/HookToEvents/HookToEvents.ino b/examples/HookToEvents/HookToEvents.ino index 3361c839..5e5f9e6e 100644 --- a/examples/HookToEvents/HookToEvents.ino +++ b/examples/HookToEvents/HookToEvents.ino @@ -14,6 +14,9 @@ void onHomieEvent(const HomieEvent& event) { case HomieEventType::OTA_STARTED: Serial << "OTA started" << endl; break; + case HomieEventType::OTA_PROGRESS: + Serial << "OTA progress, " << event.sizeDone << "/" << event.sizeTotal << endl; + break; case HomieEventType::OTA_FAILED: Serial << "OTA failed" << endl; break; @@ -29,7 +32,7 @@ void onHomieEvent(const HomieEvent& event) { case HomieEventType::WIFI_DISCONNECTED: Serial << "Wi-Fi disconnected, reason: " << (int8_t)event.wifiReason << endl; break; - case HomieEventType::MQTT_CONNECTED: + case HomieEventType::MQTT_READY: Serial << "MQTT connected" << endl; break; case HomieEventType::MQTT_DISCONNECTED: diff --git a/examples/SonoffDualShutters/SonoffDualShutters.ino b/examples/SonoffDualShutters/SonoffDualShutters.ino new file mode 100644 index 00000000..09e12c61 --- /dev/null +++ b/examples/SonoffDualShutters/SonoffDualShutters.ino @@ -0,0 +1,128 @@ +/* + +# Homie enabled Sonoff Dual shutters + +Requires the Shutters library: +https://github.com/marvinroger/arduino-shutters +and the SonoffDual library: +https://github.com/marvinroger/arduino-sonoff-dual + +## Features + +* Do a short press to close shutters +if level != 0 or open shutters if level == 0 +* Do a long press to reset + +*/ + +#include + +#include +#include +#include + +const unsigned long COURSE_TIME = 30 * 1000; +const float CALIBRATION_RATIO = 0.1; + +const bool RELAY1_MOVE = true; +const bool RELAY1_STOP = false; + +const bool RELAY2_UP = true; +const bool RELAY2_DOWN = false; + +const byte SHUTTERS_EEPROM_POSITION = 0; + +HomieNode shuttersNode("shutters", "shutters"); + +// Shutters + +void shuttersUp() { + SonoffDual.setRelays(RELAY1_MOVE, RELAY2_UP); +} + +void shuttersDown() { + SonoffDual.setRelays(RELAY1_MOVE, RELAY2_DOWN); +} + +void shuttersHalt() { + SonoffDual.setRelays(RELAY1_STOP, false); +} + +uint8_t shuttersGetState() { + return EEPROM.read(SHUTTERS_EEPROM_POSITION); +} + +void shuttersSetState(uint8_t state) { + EEPROM.write(SHUTTERS_EEPROM_POSITION, state); + EEPROM.commit(); +} + +Shutters shutters(COURSE_TIME, shuttersUp, shuttersDown, shuttersHalt, shuttersGetState, shuttersSetState, CALIBRATION_RATIO, onShuttersLevelReached); + +void onShuttersLevelReached(uint8_t level) { + if (shutters.isIdle()) Homie.setIdle(true); // if idle, we've reached our target + if (Homie.isConnected()) shuttersNode.setProperty("level").send(String(level)); +} + +// Homie + +void onHomieEvent(const HomieEvent& event) { + switch (event.type) { + case HomieEventType::ABOUT_TO_RESET: + shutters.reset(); + break; + } +} + +bool shuttersLevelHandler(const HomieRange& range, const String& value) { + for (byte i = 0; i < value.length(); i++) { + if (isDigit(value.charAt(i)) == false) return false; + } + + const unsigned long numericValue = value.toInt(); + if (numericValue > 100) return false; + + // wanted value is valid + + if (shutters.isIdle() && numericValue == shutters.getCurrentLevel()) return true; // nothing to do + + Homie.setIdle(false); + shutters.setLevel(numericValue); + + return true; +} + +// Logic + +void setup() { + SonoffDual.setup(); + EEPROM.begin(4); + shutters.begin(); + + Homie_setFirmware("sonoff-dual-shutters", "1.0.0"); + Homie.disableLogging(); + Homie.disableResetTrigger(); + Homie.setLedPin(SonoffDual.LED_PIN, SonoffDual.LED_ON); + Homie.onEvent(onHomieEvent); + + shuttersNode.advertise("level").settable(shuttersLevelHandler); + + Homie.setup(); +} + +void loop() { + shutters.loop(); + Homie.loop(); + SonoffDualButton buttonState = SonoffDual.handleButton(); + if (buttonState == SonoffDualButton::LONG) { + Homie.reset(); + } else if (buttonState == SonoffDualButton::SHORT && shutters.isIdle()) { + Homie.setIdle(false); + + if (shutters.getCurrentLevel() == 100) { + shutters.setLevel(0); + } else { + shutters.setLevel(100); + } + } +} diff --git a/keywords.txt b/keywords.txt index 5f1947db..a983208d 100644 --- a/keywords.txt +++ b/keywords.txt @@ -41,6 +41,7 @@ getConfiguration KEYWORD2 getMqttClient KEYWORD2 getLogger KEYWORD2 prepareToSleep KEYWORD2 +doDeepSleep KEYWORD2 # HomieNode @@ -80,12 +81,13 @@ STANDALONE_MODE LITERAL1 CONFIGURATION_MODE LITERAL1 NORMAL_MODE LITERAL1 OTA_STARTED LITERAL1 +OTA_PROGRESS LITERAL1 OTA_FAILED LITERAL1 OTA_SUCCESSFUL LITERAL1 ABOUT_TO_RESET LITERAL1 WIFI_CONNECTED LITERAL1 WIFI_DISCONNECTED LITERAL1 -MQTT_CONNECTED LITERAL1 +MQTT_READY LITERAL1 MQTT_DISCONNECTED LITERAL1 MQTT_PACKET_ACKNOWLEDGED LITERAL1 READY_TO_SLEEP LITERAL1 diff --git a/library.json b/library.json index 5ea109dc..7704c58e 100644 --- a/library.json +++ b/library.json @@ -3,7 +3,7 @@ "version": "2.0.0", "keywords": "iot, home, automation, mqtt, esp8266, async, sensor", "description": "ESP8266 framework for Homie, a lightweight MQTT convention for the IoT", - "homepage": "https://homie-esp8266.readme.io", + "homepage": "http://marvinroger.github.io/homie-esp8266/", "license": "MIT", "authors": { @@ -22,21 +22,18 @@ "dependencies": [ { "name": "ArduinoJson", - "version": "^5.0.0", - "authors": "Benoit Blanchon", - "frameworks": "arduino" + "version": "^5.10.0" }, { "name": "AsyncMqttClient", - "version": "^0.5.0", - "authors": "Marvin Roger", - "frameworks": "arduino" + "version": "^0.8.0" }, { "name": "Bounce2", - "version": "^2.0.0", - "authors": "Thomas O Fredericks", - "frameworks": "arduino" + "version": "^2.1.0" + }, + { + "name": "ESP Async WebServer" } ], "export": { @@ -46,6 +43,5 @@ "src/*", "examples/*" ] - }, - "examples": "examples/*.ino" + } } diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..1158a5bd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,76 @@ +site_name: Homie for ESP8266 +site_description: The Homie for ESP8266 documentation. +site_author: Marvin ROGER + +repo_name: 'marvinroger/homie-esp8266' +repo_url: 'https://github.com/marvinroger/homie-esp8266' + +edit_uri: edit/develop/docs + +pages: + - Welcome: index.md + - Quickstart: + - What is it?: quickstart/what-is-it.md + - Getting started: quickstart/getting-started.md + - Advanced usage: + - Built-in LED: advanced-usage/built-in-led.md + - Branding: advanced-usage/branding.md + - Events: advanced-usage/events.md + - Logging: advanced-usage/logging.md + - Streaming operator: advanced-usage/streaming-operator.md + - Input handlers: advanced-usage/input-handlers.md + - Broadcast: advanced-usage/broadcast.md + - Custom settings: advanced-usage/custom-settings.md + - Resetting: advanced-usage/resetting.md + - Standalone mode: advanced-usage/standalone-mode.md + - Magic bytes: advanced-usage/magic-bytes.md + - Range properties: advanced-usage/range-properties.md + - Deep sleep: advanced-usage/deep-sleep.md + - Miscellaneous: advanced-usage/miscellaneous.md + - UI Bundle: advanced-usage/ui-bundle.md + - Configuration: + - JSON configuration file: configuration/json-configuration-file.md + - HTTP JSON API: configuration/http-json-api.md + - Others: + - OTA/configuration updates: others/ota-configuration-updates.md + - Homie implementation specifics: others/homie-implementation-specifics.md + - Limitations and known issues: others/limitations-and-known-issues.md + - Troubleshooting: others/troubleshooting.md + - C++ API reference: others/cpp-api-reference.md + - Upgrade guide from v1 to v2: others/upgrade-guide-from-v1-to-v2.md + - Community projects: others/community-projects.md + +theme: + name: material + palette: + primary: red + accent: red + logo: assets/logo.png + feature: + tabs: true + +extra: + social: + - type: cog + link: http://marvinroger.github.io/homie-esp8266/configurators/v2/ + +markdown_extensions: + - meta + - footnotes + - codehilite + - admonition + - toc(permalink=true) + - pymdownx.arithmatex + - pymdownx.betterem(smart_enable=all) + - pymdownx.caret + - pymdownx.critic + - pymdownx.details + - pymdownx.emoji: + emoji_generator: !!python/name:pymdownx.emoji.to_svg + - pymdownx.inlinehilite + - pymdownx.magiclink + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences + - pymdownx.tasklist(custom_checkbox=true) + - pymdownx.tilde diff --git a/scripts/firmware_parser/README.md b/scripts/firmware_parser/README.md new file mode 100644 index 00000000..1cb6ddf9 --- /dev/null +++ b/scripts/firmware_parser/README.md @@ -0,0 +1,8 @@ +Script: Firmware parser +======================= + +This will allow you to get information about the binary firmware file. + +## Usage + +`python ./firmware_parser.py ~/firmware.bin` diff --git a/firmware_parser.py b/scripts/firmware_parser/firmware_parser.py similarity index 98% rename from firmware_parser.py rename to scripts/firmware_parser/firmware_parser.py index 5ac28058..9ed43e69 100644 --- a/firmware_parser.py +++ b/scripts/firmware_parser/firmware_parser.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python import re import sys diff --git a/scripts/ota_updater/README.md b/scripts/ota_updater/README.md new file mode 100644 index 00000000..3ce065aa --- /dev/null +++ b/scripts/ota_updater/README.md @@ -0,0 +1,48 @@ +Script: OTA updater +=================== + +This script will allow you to send an OTA update to your device. + +## Installation + +`pip install -r requirements.txt` + +## Usage + +```text +usage: ota_updater.py [-h] -l BROKER_HOST -p BROKER_PORT [-u BROKER_USERNAME] + [-d BROKER_PASSWORD] [-t BASE_TOPIC] -i DEVICE_ID + firmware + +ota firmware update scirpt for ESP8226 implemenation of the Homie mqtt IoT +convention. + +positional arguments: + firmware path to the firmware to be sent to the device + +arguments: + -h, --help show this help message and exit + -l BROKER_HOST, --broker-host BROKER_HOST + host name or ip address of the mqtt broker + -p BROKER_PORT, --broker-port BROKER_PORT + port of the mqtt broker + -u BROKER_USERNAME, --broker-username BROKER_USERNAME + username used to authenticate with the mqtt broker + -d BROKER_PASSWORD, --broker-password BROKER_PASSWORD + password used to authenticate with the mqtt broker + -t BASE_TOPIC, --base-topic BASE_TOPIC + base topic of the homie devices on the broker + -i DEVICE_ID, --device-id DEVICE_ID + homie device id +``` + +* `BROKER_HOST` and `BROKER_PORT` defaults to 127.0.0.1 and 1883 respectively if not set. +* `BROKER_USERNAME` and `BROKER_PASSWORD` are optional. +* `BASE_TOPIC` has to end with a slash, defaults to `homie/` if not set. + +### Example: + +```bash +python ota_updater.py -l localhost -u admin -d secure -t "homie/" -i "device-id" /path/to/firmware.bin +``` + diff --git a/scripts/ota_updater/ota_updater.py b/scripts/ota_updater/ota_updater.py new file mode 100755 index 00000000..2b0f1f5b --- /dev/null +++ b/scripts/ota_updater/ota_updater.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python + +from __future__ import division, print_function +import paho.mqtt.client as mqtt +import base64, sys, math +from hashlib import md5 + +# The callback for when the client receives a CONNACK response from the server. +def on_connect(client, userdata, flags, rc): + if rc != 0: + print("Connection Failed with result code {}".format(rc)) + client.disconnect() + else: + print("Connected with result code {}".format(rc)) + + # calcluate firmware md5 + firmware_md5 = md5(userdata['firmware']).hexdigest() + userdata.update({'md5': firmware_md5}) + + # Subscribing in on_connect() means that if we lose the connection and + # reconnect then subscriptions will be renewed. + client.subscribe("{base_topic}{device_id}/$implementation/ota/status".format(**userdata)) + client.subscribe("{base_topic}{device_id}/$implementation/ota/enabled".format(**userdata)) + client.subscribe("{base_topic}{device_id}/$fw/#".format(**userdata)) + + # Wait for device info to come in and invoke the on_message callback where update will continue + print("Waiting for device info...") + + +# The callback for when a PUBLISH message is received from the server. +def on_message(client, userdata, msg): + # decode string for python2/3 compatiblity + msg.payload = msg.payload.decode() + + if msg.topic.endswith('$implementation/ota/status'): + status = int(msg.payload.split()[0]) + + if userdata.get("published"): + if status == 206: # in progress + # state in progress, print progress bar + progress, total = [int(x) for x in msg.payload.split()[1].split('/')] + bar_width = 30 + bar = int(bar_width*(progress/total)) + print("\r[", '+'*bar, ' '*(bar_width-bar), "] ", msg.payload.split()[1], end='', sep='') + if (progress == total): + print() + sys.stdout.flush() + elif status == 304: # not modified + print("Device firmware already up to date with md5 checksum: {}".format(userdata.get('md5'))) + client.disconnect() + elif status == 403: # forbidden + print("Device ota disabled, aborting...") + client.disconnect() + + elif msg.topic.endswith('$fw/checksum'): + checksum = msg.payload + + if userdata.get("published"): + if checksum == userdata.get('md5'): + print("Device back online. Update Successful!") + else: + print("Expecting checksum {}, got {}, update failed!".format(userdata.get('md5'), checksum)) + client.disconnect() + else: + if checksum != userdata.get('md5'): # save old md5 for comparison with new firmware + userdata.update({'old_md5': checksum}) + else: + print("Device firmware already up to date with md5 checksum: {}".format(checksum)) + client.disconnect() + + elif msg.topic.endswith('ota/enabled'): + if msg.payload == 'true': + userdata.update({'ota_enabled': True}) + else: + print("Device ota disabled, aborting...") + client.disconnect() + + if ( not userdata.get("published") ) and ( userdata.get('ota_enabled') ) and \ + ( 'old_md5' in userdata.keys() ) and ( userdata.get('md5') != userdata.get('old_md5') ): + # push the firmware binary + userdata.update({"published": True}) + topic = "{base_topic}{device_id}/$implementation/ota/firmware/{md5}".format(**userdata) + print("Publishing new firmware with checksum {}".format(userdata.get('md5'))) + client.publish(topic, userdata['firmware']) + + +def main(broker_host, broker_port, broker_username, broker_password, base_topic, device_id, firmware): + # initialise mqtt client and register callbacks + client = mqtt.Client() + client.on_connect = on_connect + client.on_message = on_message + + # set username and password if given + if broker_username and broker_password: + client.username_pw_set(broker_username, broker_password) + + # save data to be used in the callbacks + client.user_data_set({ + "base_topic": base_topic, + "device_id": device_id, + "firmware": firmware + }) + + # start connection + print("Connecting to mqtt broker {} on port {}".format(broker_host, broker_port)) + client.connect(broker_host, broker_port, 60) + + # Blocking call that processes network traffic, dispatches callbacks and handles reconnecting. + client.loop_forever() + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser( + description='ota firmware update scirpt for ESP8226 implemenation of the Homie mqtt IoT convention.') + + # ensure base topic always ends with a '/' + def base_topic_arg(s): + s = str(s) + if not s.endswith('/'): + s = s + '/' + return s + + # specify arguments + parser.add_argument('-l', '--broker-host', type=str, required=False, + help='host name or ip address of the mqtt broker', default="127.0.0.1") + parser.add_argument('-p', '--broker-port', type=int, required=False, + help='port of the mqtt broker', default=1883) + parser.add_argument('-u', '--broker-username', type=str, required=False, + help='username used to authenticate with the mqtt broker') + parser.add_argument('-d', '--broker-password', type=str, required=False, + help='password used to authenticate with the mqtt broker') + parser.add_argument('-t', '--base-topic', type=base_topic_arg, required=False, + help='base topic of the homie devices on the broker', default="homie/") + parser.add_argument('-i', '--device-id', type=str, required=True, + help='homie device id') + parser.add_argument('firmware', type=argparse.FileType('rb'), + help='path to the firmware to be sent to the device') + + # workaround for http://bugs.python.org/issue9694 + parser._optionals.title = "arguments" + + # get and validate arguments + args = parser.parse_args() + + # read the contents of firmware into buffer + fw_buffer = args.firmware.read() + args.firmware.close() + firmware = bytearray() + firmware.extend(fw_buffer) + + # Invoke the business logic + main(args.broker_host, args.broker_port, args.broker_username, + args.broker_password, args.base_topic, args.device_id, firmware) diff --git a/scripts/ota_updater/requirements.txt b/scripts/ota_updater/requirements.txt new file mode 100644 index 00000000..75ccf28c --- /dev/null +++ b/scripts/ota_updater/requirements.txt @@ -0,0 +1 @@ +paho-mqtt >1.2.3,<=1.3.0 diff --git a/src/Homie.cpp b/src/Homie.cpp index ceafba74..ea45fb13 100644 --- a/src/Homie.cpp +++ b/src/Homie.cpp @@ -3,28 +3,29 @@ using namespace HomieInternals; HomieClass::HomieClass() -: _setupCalled(false) -, _firmwareSet(false) -, __HOMIE_SIGNATURE("\x25\x48\x4f\x4d\x49\x45\x5f\x45\x53\x50\x38\x32\x36\x36\x5f\x46\x57\x25") { + : _setupCalled(false) + , _firmwareSet(false) + , __HOMIE_SIGNATURE("\x25\x48\x4f\x4d\x49\x45\x5f\x45\x53\x50\x38\x32\x36\x36\x5f\x46\x57\x25") { strlcpy(Interface::get().brand, DEFAULT_BRAND, MAX_BRAND_LENGTH); Interface::get().bootMode = HomieBootMode::UNDEFINED; Interface::get().configurationAp.secured = false; Interface::get().led.enabled = true; - Interface::get().led.pin = BUILTIN_LED; + Interface::get().led.pin = LED_BUILTIN; Interface::get().led.on = LOW; Interface::get().reset.idle = true; Interface::get().reset.enabled = true; Interface::get().reset.triggerPin = DEFAULT_RESET_PIN; Interface::get().reset.triggerState = DEFAULT_RESET_STATE; Interface::get().reset.triggerTime = DEFAULT_RESET_TIME; - Interface::get().reset.flaggedBySketch = false; + Interface::get().reset.resetFlag = false; + Interface::get().disable = false; Interface::get().flaggedForSleep = false; Interface::get().globalInputHandler = [](const HomieNode& node, const String& property, const HomieRange& range, const String& value) { return false; }; Interface::get().broadcastHandler = [](const String& level, const String& value) { return false; }; Interface::get().setupFunction = []() {}; Interface::get().loopFunction = []() {}; Interface::get().eventHandler = [](const HomieEvent& event) {}; - Interface::get().connected = false; + Interface::get().ready = false; Interface::get()._mqttClient = &_mqttClient; Interface::get()._sendingPromise = &_sendingPromise; Interface::get()._blinker = &_blinker; @@ -39,19 +40,62 @@ HomieClass::~HomieClass() { void HomieClass::_checkBeforeSetup(const __FlashStringHelper* functionName) const { if (_setupCalled) { - Interface::get().getLogger() << F("✖ ") << functionName << F("(): has to be called before setup()") << endl; - Serial.flush(); - abort(); + String message; + message.concat(F("✖ ")); + message.concat(functionName); + message.concat(F("(): has to be called before setup()")); + Helpers::abort(message); } } void HomieClass::setup() { _setupCalled = true; + // Check if firmware is set if (!_firmwareSet) { - Interface::get().getLogger() << F("✖ Firmware name must be set before calling setup()") << endl; - Serial.flush(); - abort(); + Helpers::abort(F("✖ Firmware name must be set before calling setup()")); + return; // never reached, here for clarity + } + + // Check the max allowed setting elements + if (IHomieSetting::settings.size() > MAX_CONFIG_SETTING_SIZE) { + Helpers::abort(F("✖ Settings exceed set limit of elelement.")); + return; // never reached, here for clarity + } + + // Check if default settings values are valid + bool defaultSettingsValuesValid = true; + for (IHomieSetting* iSetting : IHomieSetting::settings) { + if (iSetting->isBool()) { + HomieSetting* setting = static_cast*>(iSetting); + if (!setting->isRequired() && !setting->validate(setting->get())) { + defaultSettingsValuesValid = false; + break; + } + } else if (iSetting->isLong()) { + HomieSetting* setting = static_cast*>(iSetting); + if (!setting->isRequired() && !setting->validate(setting->get())) { + defaultSettingsValuesValid = false; + break; + } + } else if (iSetting->isDouble()) { + HomieSetting* setting = static_cast*>(iSetting); + if (!setting->isRequired() && !setting->validate(setting->get())) { + defaultSettingsValuesValid = false; + break; + } + } else if (iSetting->isConstChar()) { + HomieSetting* setting = static_cast*>(iSetting); + if (!setting->isRequired() && !setting->validate(setting->get())) { + defaultSettingsValuesValid = false; + break; + } + } + } + + if (!defaultSettingsValuesValid) { + Helpers::abort(F("✖ Default setting value does not pass validator test")); + return; // never reached, here for clarity } // boot mode set during this boot by application before Homie.setup() @@ -85,21 +129,17 @@ void HomieClass::setup() { _boot = &_bootNormal; Interface::get().event.type = HomieEventType::NORMAL_MODE; Interface::get().eventHandler(Interface::get().event); - } else if (_selectedHomieBootMode == HomieBootMode::CONFIGURATION) { _boot = &_bootConfig; Interface::get().event.type = HomieEventType::CONFIGURATION_MODE; Interface::get().eventHandler(Interface::get().event); - } else if (_selectedHomieBootMode == HomieBootMode::STANDALONE) { _boot = &_bootStandalone; Interface::get().event.type = HomieEventType::STANDALONE_MODE; Interface::get().eventHandler(Interface::get().event); - } else { - Interface::get().getLogger() << F("✖ Boot mode invalid") << endl; - Serial.flush(); - abort(); + Helpers::abort(F("✖ Boot mode invalid")); + return; // never reached, here for clarity } _boot->setup(); @@ -158,14 +198,14 @@ HomieClass& HomieClass::setConfigurationApPassword(const char* password) { Interface::get().configurationAp.secured = true; strlcpy(Interface::get().configurationAp.password, password, MAX_WIFI_PASSWORD_LENGTH); + return *this; } void HomieClass::__setFirmware(const char* name, const char* version) { _checkBeforeSetup(F("setFirmware")); if (strlen(name) + 1 - 10 > MAX_FIRMWARE_NAME_LENGTH || strlen(version) + 1 - 10 > MAX_FIRMWARE_VERSION_LENGTH) { - Interface::get().getLogger() << F("✖ setFirmware(): either the name or version string is too long") << endl; - Serial.flush(); - abort(); + Helpers::abort(F("✖ setFirmware(): either the name or version string is too long")); + return; // never reached, here for clarity } strncpy(Interface::get().firmware.name, name + 5, strlen(name) - 10); @@ -178,9 +218,8 @@ void HomieClass::__setFirmware(const char* name, const char* version) { void HomieClass::__setBrand(const char* brand) const { _checkBeforeSetup(F("setBrand")); if (strlen(brand) + 1 - 10 > MAX_BRAND_LENGTH) { - Interface::get().getLogger() << F("✖ setBrand(): the brand string is too long") << endl; - Serial.flush(); - abort(); + Helpers::abort(F("✖ setBrand(): the brand string is too long")); + return; // never reached, here for clarity } strncpy(Interface::get().brand, brand + 5, strlen(brand) - 10); @@ -188,10 +227,14 @@ void HomieClass::__setBrand(const char* brand) const { } void HomieClass::reset() { - Interface::get().reset.flaggedBySketch = true; + Interface::get().getLogger() << F("Flagged for reset by sketch") << endl; + Interface::get().disable = true; + Interface::get().reset.resetFlag = true; } void HomieClass::reboot() { + Interface::get().getLogger() << F("Flagged for reboot by sketch") << endl; + Interface::get().disable = true; _flaggedForReboot = true; } @@ -199,15 +242,15 @@ void HomieClass::setIdle(bool idle) { Interface::get().reset.idle = idle; } -HomieClass& HomieClass::setGlobalInputHandler(GlobalInputHandler inputHandler) { +HomieClass& HomieClass::setGlobalInputHandler(const GlobalInputHandler& globalInputHandler) { _checkBeforeSetup(F("setGlobalInputHandler")); - Interface::get().globalInputHandler = inputHandler; + Interface::get().globalInputHandler = globalInputHandler; return *this; } -HomieClass& HomieClass::setBroadcastHandler(BroadcastHandler broadcastHandler) { +HomieClass& HomieClass::setBroadcastHandler(const BroadcastHandler& broadcastHandler) { _checkBeforeSetup(F("setBroadcastHandler")); Interface::get().broadcastHandler = broadcastHandler; @@ -215,7 +258,7 @@ HomieClass& HomieClass::setBroadcastHandler(BroadcastHandler broadcastHandler) { return *this; } -HomieClass& HomieClass::setSetupFunction(OperationFunction function) { +HomieClass& HomieClass::setSetupFunction(const OperationFunction& function) { _checkBeforeSetup(F("setSetupFunction")); Interface::get().setupFunction = function; @@ -223,7 +266,7 @@ HomieClass& HomieClass::setSetupFunction(OperationFunction function) { return *this; } -HomieClass& HomieClass::setLoopFunction(OperationFunction function) { +HomieClass& HomieClass::setLoopFunction(const OperationFunction& function) { _checkBeforeSetup(F("setLoopFunction")); Interface::get().loopFunction = function; @@ -247,10 +290,10 @@ bool HomieClass::isConfigured() { } bool HomieClass::isConnected() { - return Interface::get().connected; + return Interface::get().ready; } -HomieClass& HomieClass::onEvent(EventHandler handler) { +HomieClass& HomieClass::onEvent(const EventHandler& handler) { _checkBeforeSetup(F("onEvent")); Interface::get().eventHandler = handler; @@ -290,13 +333,22 @@ Logger& HomieClass::getLogger() { } void HomieClass::prepareToSleep() { - if (Interface::get().connected) { + Interface::get().getLogger() << F("Flagged for sleep by sketch") << endl; + if (Interface::get().ready) { + Interface::get().disable = true; Interface::get().flaggedForSleep = true; } else { + Interface::get().disable = true; Interface::get().getLogger() << F("Triggering READY_TO_SLEEP event...") << endl; Interface::get().event.type = HomieEventType::READY_TO_SLEEP; Interface::get().eventHandler(Interface::get().event); } } +void HomieClass::doDeepSleep(uint32_t time_us, RFMode mode) { + Interface::get().getLogger() << F("💤 Device is deep sleeping...") << endl; + Serial.flush(); + ESP.deepSleep(time_us, mode); +} + HomieClass Homie; diff --git a/src/Homie.hpp b/src/Homie.hpp index a39dc4d9..08461d40 100644 --- a/src/Homie.hpp +++ b/src/Homie.hpp @@ -2,7 +2,7 @@ #include "Arduino.h" -#include +#include "AsyncMqttClient.h" #include "Homie/Datatypes/Interface.hpp" #include "Homie/Constants.hpp" #include "Homie/Limits.hpp" @@ -22,6 +22,8 @@ #include "HomieSetting.hpp" #include "StreamingOperator.hpp" +// Define DEBUG for debug + #define Homie_setFirmware(name, version) const char* __FLAGGED_FW_NAME = "\xbf\x84\xe4\x13\x54" name "\x93\x44\x6b\xa7\x75"; const char* __FLAGGED_FW_VERSION = "\x6a\x3f\x3e\x0e\xe1" version "\xb0\x30\x48\xd4\x1a"; Homie.__setFirmware(__FLAGGED_FW_NAME, __FLAGGED_FW_VERSION); #define Homie_setBrand(brand) const char* __FLAGGED_BRAND = "\xfb\x2a\xf5\x68\xc0" brand "\x6e\x2f\x0f\xeb\x2d"; Homie.__setBrand(__FLAGGED_BRAND); @@ -44,13 +46,13 @@ class HomieClass { HomieClass& disableLedFeedback(); HomieClass& setLedPin(uint8_t pin, uint8_t on); HomieClass& setConfigurationApPassword(const char* password); - HomieClass& setGlobalInputHandler(GlobalInputHandler globalInputHandler); - HomieClass& setBroadcastHandler(BroadcastHandler broadcastHandler); - HomieClass& onEvent(EventHandler handler); + HomieClass& setGlobalInputHandler(const GlobalInputHandler& globalInputHandler); + HomieClass& setBroadcastHandler(const BroadcastHandler& broadcastHandler); + HomieClass& onEvent(const EventHandler& handler); HomieClass& setResetTrigger(uint8_t pin, uint8_t state, uint16_t time); HomieClass& disableResetTrigger(); - HomieClass& setSetupFunction(OperationFunction function); - HomieClass& setLoopFunction(OperationFunction function); + HomieClass& setSetupFunction(const OperationFunction& function); + HomieClass& setLoopFunction(const OperationFunction& function); HomieClass& setHomieBootMode(HomieBootMode bootMode); HomieClass& setHomieBootModeOnNextBoot(HomieBootMode bootMode); @@ -63,6 +65,7 @@ class HomieClass { AsyncMqttClient& getMqttClient(); Logger& getLogger(); static void prepareToSleep(); + static void doDeepSleep(uint32_t time_us = 0, RFMode mode = RF_DEFAULT); private: bool _setupCalled; diff --git a/src/Homie/Boot/BootConfig.cpp b/src/Homie/Boot/BootConfig.cpp index 5eaf51c7..d0f5bf28 100644 --- a/src/Homie/Boot/BootConfig.cpp +++ b/src/Homie/Boot/BootConfig.cpp @@ -3,16 +3,18 @@ using namespace HomieInternals; BootConfig::BootConfig() -: Boot("config") -, _http(80) -, _ssidCount(0) -, _wifiScanAvailable(false) -, _lastWifiScanEnded(true) -, _jsonWifiNetworks() -, _flaggedForReboot(false) -, _flaggedForRebootAt(0) -, _proxyEnabled(false) -, _apIpStr({'\0'}) { + : Boot("config") + , _http(80) + , _httpClient() + , _ssidCount(0) + , _wifiScanAvailable(false) + , _lastWifiScanEnded(true) + , _jsonWifiNetworks() + , _flaggedForReboot(false) + , _flaggedForRebootAt(0) + , _proxyEnabled(false) + , _apIpStr{ '\0' } +{ _wifiScanTimer.setInterval(CONFIG_SCAN_INTERVAL); } @@ -42,118 +44,163 @@ void BootConfig::setup() { WiFi.softAP(apName); } - snprintf(_apIpStr, MAX_IP_STRING_LENGTH, "%d.%d.%d.%d", ACCESS_POINT_IP[0], ACCESS_POINT_IP[1], ACCESS_POINT_IP[2], ACCESS_POINT_IP[3]); + Helpers::ipToString(ACCESS_POINT_IP, _apIpStr); Interface::get().getLogger() << F("AP started as ") << apName << F(" with IP ") << _apIpStr << endl; _dns.setTTL(30); _dns.setErrorReplyCode(DNSReplyCode::NoError); _dns.start(53, F("*"), ACCESS_POINT_IP); - _http.on("/heart", HTTP_GET, [this]() { + __setCORS(); + _http.on("/heart", HTTP_GET, [this](AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received heart request") << endl; - _http.send(204); + request->send(204); }); - _http.on("/device-info", HTTP_GET, std::bind(&BootConfig::_onDeviceInfoRequest, this)); - _http.on("/networks", HTTP_GET, std::bind(&BootConfig::_onNetworksRequest, this)); - _http.on("/config", HTTP_PUT, std::bind(&BootConfig::_onConfigRequest, this)); - _http.on("/config", HTTP_OPTIONS, [this]() { // CORS - Interface::get().getLogger() << F("Received CORS request for /config") << endl; - _http.sendContent(FPSTR(PROGMEM_CONFIG_CORS)); - }); - _http.on("/wifi/connect", HTTP_PUT, std::bind(&BootConfig::_onWifiConnectRequest, this)); - _http.on("/wifi/connect", HTTP_OPTIONS, [this]() { // CORS - Interface::get().getLogger() << F("Received CORS request for /wifi/connect") << endl; - _http.sendContent(FPSTR(PROGMEM_CONFIG_CORS)); + _http.on("/device-info", HTTP_GET, [this](AsyncWebServerRequest *request) { _onDeviceInfoRequest(request); }); + _http.on("/networks", HTTP_GET, [this](AsyncWebServerRequest *request) { _onNetworksRequest(request); }); + _http.on("/config", HTTP_PUT, [this](AsyncWebServerRequest *request) { _onConfigRequest(request); }).onBody(BootConfig::__parsePost); + _http.on("/wifi/connect", HTTP_PUT, [this](AsyncWebServerRequest *request) { _onWifiConnectRequest(request); }).onBody(BootConfig::__parsePost); + _http.on("/wifi/status", HTTP_GET, [this](AsyncWebServerRequest *request) { _onWifiStatusRequest(request); }); + _http.on("/proxy/control", HTTP_PUT, [this](AsyncWebServerRequest *request) { _onProxyControlRequest(request); }).onBody(BootConfig::__parsePost); + _http.onNotFound([this](AsyncWebServerRequest *request) { + if ( request->method() == HTTP_OPTIONS ) { + Interface::get().getLogger() << F("Received CORS request for ")<< request->url() << endl; + request->send(200); + } else { + _onCaptivePortal(request); + } }); - _http.on("/wifi/status", HTTP_GET, std::bind(&BootConfig::_onWifiStatusRequest, this)); - _http.on("/proxy/control", HTTP_PUT, std::bind(&BootConfig::_onProxyControlRequest, this)); - _http.onNotFound(std::bind(&BootConfig::_onCaptivePortal, this)); _http.begin(); } -void BootConfig::_onWifiConnectRequest() { +void BootConfig::loop() { + Boot::loop(); + + _dns.processNextRequest(); + + if (_flaggedForReboot) { + if (millis() - _flaggedForRebootAt >= 3000UL) { + Interface::get().getLogger() << F("↻ Rebooting into normal mode...") << endl; + Serial.flush(); + ESP.restart(); + } + + return; + } + + if (!_lastWifiScanEnded) { + int8_t scanResult = WiFi.scanComplete(); + + switch (scanResult) { + case WIFI_SCAN_RUNNING: + return; + case WIFI_SCAN_FAILED: + Interface::get().getLogger() << F("✖ Wi-Fi scan failed") << endl; + _ssidCount = 0; + _wifiScanTimer.reset(); + break; + default: + Interface::get().getLogger() << F("✔ Wi-Fi scan completed") << endl; + _ssidCount = scanResult; + _generateNetworksJson(); + _wifiScanAvailable = true; + break; + } + + _lastWifiScanEnded = true; + } + + if (_lastWifiScanEnded && _wifiScanTimer.check()) { + Interface::get().getLogger() << F("Triggering Wi-Fi scan...") << endl; + WiFi.scanNetworks(true); + _wifiScanTimer.tick(); + _lastWifiScanEnded = false; + } +} + +void BootConfig::_onWifiConnectRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received Wi-Fi connect request") << endl; - StaticJsonBuffer parseJsonBuffer; - std::unique_ptr bodyString = Helpers::cloneString(_http.arg("plain")); - JsonObject& parsedJson = parseJsonBuffer.parseObject(bodyString.get()); + DynamicJsonBuffer parseJsonBuffer(JSON_OBJECT_SIZE(2)); + const char* body = (const char*)(request->_tempObject); + JsonObject& parsedJson = parseJsonBuffer.parseObject(body); if (!parsedJson.success()) { - Interface::get().getLogger() << F("✖ Invalid or too big JSON") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("Invalid or too big JSON\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ Invalid or too big JSON")); return; } if (!parsedJson.containsKey("ssid") || !parsedJson["ssid"].is() || !parsedJson.containsKey("password") || !parsedJson["password"].is()) { - Interface::get().getLogger() << F("✖ SSID and password required") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("SSID and password required\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ SSID and password required")); return; } Interface::get().getLogger() << F("Connecting to Wi-Fi") << endl; WiFi.begin(parsedJson["ssid"].as(), parsedJson["password"].as()); - _http.send(202, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), F("{\"success\":true}")); + + request->send(202, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), FPSTR(PROGMEM_CONFIG_JSON_SUCCESS)); } -void BootConfig::_onWifiStatusRequest() { +void BootConfig::_onWifiStatusRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received Wi-Fi status request") << endl; - String json = ""; + + DynamicJsonBuffer generatedJsonBuffer(JSON_OBJECT_SIZE(2)); + JsonObject& json = generatedJsonBuffer.createObject(); + String status; + + //String json = ""; switch (WiFi.status()) { - case WL_IDLE_STATUS: - json = F("{\"status\":\"idle\"}"); - break; - case WL_CONNECT_FAILED: - json = F("{\"status\":\"connect_failed\"}"); - break; - case WL_CONNECTION_LOST: - json = F("{\"status\":\"connection_lost\"}"); - break; - case WL_NO_SSID_AVAIL: - json = F("{\"status\":\"no_ssid_available\"}"); - break; - case WL_CONNECTED: - json = "{\"status\":\"connected\",\"local_ip\":\"" + WiFi.localIP().toString() + "\"}"; - break; - case WL_DISCONNECTED: - json = F("{\"status\":\"disconnected\"}"); - break; - default: - json = F("{\"status\":\"other\"}"); - break; + case WL_IDLE_STATUS: + status = F("idle"); + break; + case WL_CONNECT_FAILED: + status = F("connect_failed"); + break; + case WL_CONNECTION_LOST: + status = F("connection_lost"); + break; + case WL_NO_SSID_AVAIL: + status = F("no_ssid_available"); + break; + case WL_CONNECTED: + status = F("connected"); + json["local_ip"] = WiFi.localIP().toString(); + break; + case WL_DISCONNECTED: + status = F("disconnected"); + break; + default: + status = F("other"); + break; } - _http.send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), json); + json["status"] = status; + String output; + json.printTo(output); + + request->send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), output); } -void BootConfig::_onProxyControlRequest() { +void BootConfig::_onProxyControlRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received proxy control request") << endl; - StaticJsonBuffer parseJsonBuffer; - std::unique_ptr bodyString = Helpers::cloneString(_http.arg("plain")); - JsonObject& parsedJson = parseJsonBuffer.parseObject(bodyString.get()); // do not use plain String, else fails + DynamicJsonBuffer parseJsonBuffer(JSON_OBJECT_SIZE(1)); + const char* body = (const char*)(request->_tempObject); + JsonObject& parsedJson = parseJsonBuffer.parseObject(body); // do not use plain String, else fails if (!parsedJson.success()) { - Interface::get().getLogger() << F("✖ Invalid or too big JSON") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("Invalid or too big JSON\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ Invalid or too big JSON")); return; } if (!parsedJson.containsKey("enable") || !parsedJson["enable"].is()) { - Interface::get().getLogger() << F("✖ enable parameter is required") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("enable parameter is required\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ enable parameter is required")); return; } _proxyEnabled = parsedJson["enable"]; - _http.send(202, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), F("{\"success\":true}")); + + request->send(202, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), FPSTR(PROGMEM_CONFIG_JSON_SUCCESS)); } void BootConfig::_generateNetworksJson() { - DynamicJsonBuffer generatedJsonBuffer = DynamicJsonBuffer(JSON_OBJECT_SIZE(1) + JSON_ARRAY_SIZE(_ssidCount) + (_ssidCount * JSON_OBJECT_SIZE(3))); // 1 at root, 3 in childrend + DynamicJsonBuffer generatedJsonBuffer(JSON_OBJECT_SIZE(1) + JSON_ARRAY_SIZE(_ssidCount) + (_ssidCount * JSON_OBJECT_SIZE(3))); // 1 at root, 3 in childrend JsonObject& json = generatedJsonBuffer.createObject(); JsonArray& networks = json.createNestedArray("networks"); @@ -162,104 +209,113 @@ void BootConfig::_generateNetworksJson() { jsonNetwork["ssid"] = WiFi.SSID(network); jsonNetwork["rssi"] = WiFi.RSSI(network); switch (WiFi.encryptionType(network)) { - case ENC_TYPE_WEP: - jsonNetwork["encryption"] = "wep"; - break; - case ENC_TYPE_TKIP: - jsonNetwork["encryption"] = "wpa"; - break; - case ENC_TYPE_CCMP: - jsonNetwork["encryption"] = "wpa2"; - break; - case ENC_TYPE_NONE: - jsonNetwork["encryption"] = "none"; - break; - case ENC_TYPE_AUTO: - jsonNetwork["encryption"] = "auto"; - break; + case ENC_TYPE_WEP: + jsonNetwork["encryption"] = "wep"; + break; + case ENC_TYPE_TKIP: + jsonNetwork["encryption"] = "wpa"; + break; + case ENC_TYPE_CCMP: + jsonNetwork["encryption"] = "wpa2"; + break; + case ENC_TYPE_NONE: + jsonNetwork["encryption"] = "none"; + break; + case ENC_TYPE_AUTO: + jsonNetwork["encryption"] = "auto"; + break; } networks.add(jsonNetwork); } - delete[] _jsonWifiNetworks; - size_t jsonBufferLength = json.measureLength() + 1; - _jsonWifiNetworks = new char[jsonBufferLength]; - json.printTo(_jsonWifiNetworks, jsonBufferLength); + String output; + json.printTo(output); + _jsonWifiNetworks = output; } -void BootConfig::_onCaptivePortal() { - String host = _http.hostHeader(); +void BootConfig::_onCaptivePortal(AsyncWebServerRequest *request) { + String host = request->host(); + Interface::get().getLogger() << F("Received captive portal request: "); if (host && !host.equals(_apIpStr)) { // redirect unknown host requests to self if not connected to Internet yet if (!_proxyEnabled) { - Interface::get().getLogger() << F("Received captive portal request") << endl; // Catch any captive portal probe. // Every browser brand uses a different URL for this purpose // We MUST redirect all them to local webserver to prevent cache poisoning String redirectUrl = String("http://"); redirectUrl.concat(_apIpStr); - _http.sendHeader(F("Location"), redirectUrl); - _http.send(302, F("text/plain"), F("")); - // perform transparent proxy to Internet if connected + Interface::get().getLogger() << F("Redirect: ") << redirectUrl << endl; + request->redirect(redirectUrl); } else { - _proxyHttpRequest(); + // perform transparent proxy to Internet if connected + Interface::get().getLogger() << F("Proxy") << endl; + _proxyHttpRequest(request); } - } else if (_http.uri() != "/" || !SPIFFS.exists(CONFIG_UI_BUNDLE_PATH)) { - Interface::get().getLogger() << F("Received not found request") << endl; - _http.send(404, F("text/plain"), F("UI bundle not loaded. See Configuration API usage: https://homie-esp8266.readme.io/docs/http-json-api")); + } else if (request->url() == "/" && !SPIFFS.exists(CONFIG_UI_BUNDLE_PATH)) { + // UI File not found + String msg = String(F("UI bundle not loaded. See Configuration API usage: http://marvinroger.github.io/homie-esp8266/")); + Interface::get().getLogger() << msg << endl; + request->send(404, F("text/plain"), msg); + } else if (request->url() == "/" && SPIFFS.exists(CONFIG_UI_BUNDLE_PATH)) { + // Respond with UI + Interface::get().getLogger() << F("UI bundle found") << endl; + AsyncWebServerResponse *response = request->beginResponse(SPIFFS.open(CONFIG_UI_BUNDLE_PATH, "r"), F("index.html"), F("text/html")); + request->send(response); } else { - Interface::get().getLogger() << F("Received UI request") << endl; - File file = SPIFFS.open(CONFIG_UI_BUNDLE_PATH, "r"); - _http.streamFile(file, F("text/html")); - file.close(); + // Faild to find request + String msg = String(F("Request NOT found for url: ")) + request->url(); + Interface::get().getLogger() << msg << endl; + request->send(404, F("text/plain"), msg); } } -void BootConfig::_proxyHttpRequest() { +void BootConfig::_proxyHttpRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received transparent proxy request") << endl; String url = String("http://"); - url.concat(_http.hostHeader()); - url.concat(_http.uri()); + url.concat(request->host()); + url.concat(request->url()); // send request to destination (as in incoming host header) _httpClient.setUserAgent(F("ESP8266-Homie")); _httpClient.begin(url); // copy headers - for (int i = 0; i < _http.headers(); i++) { - _httpClient.addHeader(_http.headerName(i), _http.header(i)); + for (size_t i = 0; i < request->headers(); i++) { + _httpClient.addHeader(request->headerName(i), request->header(i)); } String method = ""; - switch (_http.method()) { - case HTTP_GET: method = F("GET"); break; - case HTTP_PUT: method = F("PUT"); break; - case HTTP_POST: method = F("POST"); break; - case HTTP_DELETE: method = F("DELETE"); break; - case HTTP_OPTIONS: method = F("OPTIONS"); break; - default: break; + switch (request->method()) { + case HTTP_GET: method = F("GET"); break; + case HTTP_PUT: method = F("PUT"); break; + case HTTP_POST: method = F("POST"); break; + case HTTP_DELETE: method = F("DELETE"); break; + case HTTP_OPTIONS: method = F("OPTIONS"); break; + default: break; } Interface::get().getLogger() << F("Proxy sent request to destination") << endl; - int _httpCode = _httpClient.sendRequest(method.c_str(), _http.arg("plain")); + const char* body = (const char*)(request->_tempObject); + int _httpCode = _httpClient.sendRequest(method.c_str(), body); Interface::get().getLogger() << F("Destination response code = ") << _httpCode << endl; // bridge response to browser // copy response headers + Interface::get().getLogger() << F("Bridging received destination contents to client") << endl; + AsyncWebServerResponse* response = request->beginResponse(_httpCode, _httpClient.header("Content-Type"), _httpClient.getString()); for (int i = 0; i < _httpClient.headers(); i++) { - _http.sendHeader(_httpClient.headerName(i), _httpClient.header(i), false); + response->addHeader(_httpClient.headerName(i), _httpClient.header(i)); } - Interface::get().getLogger() << F("Bridging received destination contents to client") << endl; - _http.send(_httpCode, _httpClient.header("Content-Type"), _httpClient.getString()); + request->send(response); _httpClient.end(); } -void BootConfig::_onDeviceInfoRequest() { +void BootConfig::_onDeviceInfoRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received device information request") << endl; auto numSettings = IHomieSetting::settings.size(); auto numNodes = HomieNode::nodes.size(); - DynamicJsonBuffer jsonBuffer = DynamicJsonBuffer(JSON_OBJECT_SIZE(5) + JSON_OBJECT_SIZE(2) + JSON_ARRAY_SIZE(numNodes) + (numNodes * JSON_OBJECT_SIZE(2)) + JSON_ARRAY_SIZE(numSettings) + (numSettings * JSON_OBJECT_SIZE(5))); + DynamicJsonBuffer jsonBuffer(JSON_OBJECT_SIZE(5) + JSON_OBJECT_SIZE(2) + JSON_ARRAY_SIZE(numNodes) + (numNodes * JSON_OBJECT_SIZE(2)) + JSON_ARRAY_SIZE(numSettings) + (numSettings * JSON_OBJECT_SIZE(5))); JsonObject& json = jsonBuffer.createObject(); json["hardware_device_id"] = DeviceId::get(); json["homie_esp8266_version"] = HOMIE_ESP8266_VERSION; @@ -278,91 +334,66 @@ void BootConfig::_onDeviceInfoRequest() { JsonArray& settings = json.createNestedArray("settings"); for (IHomieSetting* iSetting : IHomieSetting::settings) { JsonObject& jsonSetting = jsonBuffer.createObject(); - if (iSetting->isBool()) { - HomieSetting* setting = static_cast*>(iSetting); - jsonSetting["name"] = setting->getName(); - jsonSetting["description"] = setting->getDescription(); - jsonSetting["type"] = "bool"; - jsonSetting["required"] = setting->isRequired(); - if (!setting->isRequired()) { - jsonSetting["default"] = setting->get(); - } - } else if (iSetting->isLong()) { - HomieSetting* setting = static_cast*>(iSetting); - jsonSetting["name"] = setting->getName(); - jsonSetting["description"] = setting->getDescription(); - jsonSetting["type"] = "long"; - jsonSetting["required"] = setting->isRequired(); - if (!setting->isRequired()) { - jsonSetting["default"] = setting->get(); - } - } else if (iSetting->isDouble()) { - HomieSetting* setting = static_cast*>(iSetting); - jsonSetting["name"] = setting->getName(); - jsonSetting["description"] = setting->getDescription(); - jsonSetting["type"] = "double"; - jsonSetting["required"] = setting->isRequired(); - if (!setting->isRequired()) { - jsonSetting["default"] = setting->get(); - } - } else if (iSetting->isConstChar()) { - HomieSetting* setting = static_cast*>(iSetting); - jsonSetting["name"] = setting->getName(); - jsonSetting["description"] = setting->getDescription(); - jsonSetting["type"] = "string"; - jsonSetting["required"] = setting->isRequired(); - if (!setting->isRequired()) { - jsonSetting["default"] = setting->get(); + + if (strcmp(iSetting->getType(), "unknown") != 0) { + jsonSetting["name"] = iSetting->getName(); + jsonSetting["description"] = iSetting->getDescription(); + jsonSetting["type"] = iSetting->getType(); + jsonSetting["required"] = iSetting->isRequired(); + + if (!iSetting->isRequired()) { + if (iSetting->isBool()) { + HomieSetting* setting = static_cast*>(iSetting); + jsonSetting["default"] = setting->get(); + } else if (iSetting->isLong()) { + HomieSetting* setting = static_cast*>(iSetting); + jsonSetting["default"] = setting->get(); + } else if (iSetting->isDouble()) { + HomieSetting* setting = static_cast*>(iSetting); + jsonSetting["default"] = setting->get(); + } else if (iSetting->isConstChar()) { + HomieSetting* setting = static_cast*>(iSetting); + jsonSetting["default"] = setting->get(); + } } } settings.add(jsonSetting); } - size_t jsonBufferLength = json.measureLength() + 1; - std::unique_ptr jsonString(new char[jsonBufferLength]); - json.printTo(jsonString.get(), jsonBufferLength); - _http.send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), jsonString.get()); + String output; + json.printTo(output); + + request->send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), output); } -void BootConfig::_onNetworksRequest() { +void BootConfig::_onNetworksRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received networks request") << endl; if (_wifiScanAvailable) { - _http.send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), _jsonWifiNetworks); + request->send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), _jsonWifiNetworks); } else { - _http.send(503, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), FPSTR(PROGMEM_CONFIG_NETWORKS_FAILURE)); + __SendJSONError(request, F("Initial Wi-Fi scan not finished yet"), 503); } } -void BootConfig::_onConfigRequest() { +void BootConfig::_onConfigRequest(AsyncWebServerRequest *request) { Interface::get().getLogger() << F("Received config request") << endl; if (_flaggedForReboot) { - Interface::get().getLogger() << F("✖ Device already configured") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("Device already configured\"}")); - _http.send(403, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ Device already configured"), 403); return; } - StaticJsonBuffer parseJsonBuffer; - std::unique_ptr bodyString = Helpers::cloneString(_http.arg("plain")); - JsonObject& parsedJson = parseJsonBuffer.parseObject(bodyString.get()); // workaround, cannot pass raw String otherwise JSON parsing fails randomly + DynamicJsonBuffer parseJsonBuffer(MAX_JSON_CONFIG_ARDUINOJSON_BUFFER_SIZE); + const char* body = (const char*)(request->_tempObject); + JsonObject& parsedJson = parseJsonBuffer.parseObject(body); if (!parsedJson.success()) { - Interface::get().getLogger() << F("✖ Invalid or too big JSON") << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("Invalid or too big JSON\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, F("✖ Invalid or too big JSON")); return; } ConfigValidationResult configValidationResult = Validation::validateConfig(parsedJson); if (!configValidationResult.valid) { - Interface::get().getLogger() << F("✖ Config file is not valid, reason: ") << configValidationResult.reason << endl; - String errorJson = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); - errorJson.concat(F("Config file is not valid, reason: ")); - errorJson.concat(configValidationResult.reason); - errorJson.concat(F("\"}")); - _http.send(400, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); + __SendJSONError(request, String(F("✖ Config file is not valid, reason: ")) + configValidationResult.reason); return; } @@ -370,54 +401,39 @@ void BootConfig::_onConfigRequest() { Interface::get().getLogger() << F("✔ Configured") << endl; - _http.send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), F("{\"success\":true}")); + request->send(200, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), FPSTR(PROGMEM_CONFIG_JSON_SUCCESS)); + Interface::get().disable = true; _flaggedForReboot = true; // We don't reboot immediately, otherwise the response above is not sent _flaggedForRebootAt = millis(); } -void BootConfig::loop() { - Boot::loop(); - - _dns.processNextRequest(); - _http.handleClient(); +void BootConfig::__setCORS() { + DefaultHeaders::Instance().addHeader(F("Access-Control-Allow-Origin"), F("*")); + DefaultHeaders::Instance().addHeader(F("Access-Control-Allow-Methods"), F("GET, PUT")); + DefaultHeaders::Instance().addHeader(F("Access-Control-Allow-Headers"), F("Content-Type, Origin, Referer, User-Agent")); +} - if (_flaggedForReboot) { - if (millis() - _flaggedForRebootAt >= 3000UL) { - Interface::get().getLogger() << F("↻ Rebooting into normal mode...") << endl; - Serial.flush(); - ESP.restart(); +void BootConfig::__parsePost(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + if (total > MAX_POST_SIZE) { + Interface::get().getLogger() << F("Request is to large to be processed.") << endl; + } else { + if (index == 0) { + request->_tempObject = new char[total + 1]; } - - return; - } - - if (!_lastWifiScanEnded) { - int8_t scanResult = WiFi.scanComplete(); - - switch (scanResult) { - case WIFI_SCAN_RUNNING: - return; - case WIFI_SCAN_FAILED: - Interface::get().getLogger() << F("✖ Wi-Fi scan failed") << endl; - _ssidCount = 0; - _wifiScanTimer.reset(); - break; - default: - Interface::get().getLogger() << F("✔ Wi-Fi scan completed") << endl; - _ssidCount = scanResult; - _generateNetworksJson(); - _wifiScanAvailable = true; - break; + char* buff = reinterpret_cast(request->_tempObject) + index; + memcpy(buff, data, len); + if (index + len == total) { + char* buff = reinterpret_cast(request->_tempObject) + total; + *buff = '\0'; } - - _lastWifiScanEnded = true; } +} - if (_lastWifiScanEnded && _wifiScanTimer.check()) { - Interface::get().getLogger() << F("Triggering Wi-Fi scan...") << endl; - WiFi.scanNetworks(true); - _wifiScanTimer.tick(); - _lastWifiScanEnded = false; - } +void HomieInternals::BootConfig::__SendJSONError(AsyncWebServerRequest * request, String msg, int16_t code) { + Interface::get().getLogger() << msg << endl; + const String BEGINNING = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_BEGINNING)); + const String END = String(FPSTR(PROGMEM_CONFIG_JSON_FAILURE_END)); + String errorJson = BEGINNING + msg + END; + request->send(code, FPSTR(PROGMEM_CONFIG_APPLICATION_JSON), errorJson); } diff --git a/src/Homie/Boot/BootConfig.hpp b/src/Homie/Boot/BootConfig.hpp index cbff972e..6893de60 100644 --- a/src/Homie/Boot/BootConfig.hpp +++ b/src/Homie/Boot/BootConfig.hpp @@ -4,8 +4,9 @@ #include #include -#include #include +#include +#include #include #include #include "Boot.hpp" @@ -30,27 +31,33 @@ class BootConfig : public Boot { void loop(); private: + AsyncWebServer _http; HTTPClient _httpClient; - ESP8266WebServer _http; DNSServer _dns; uint8_t _ssidCount; bool _wifiScanAvailable; Timer _wifiScanTimer; bool _lastWifiScanEnded; - char* _jsonWifiNetworks; + String _jsonWifiNetworks; bool _flaggedForReboot; uint32_t _flaggedForRebootAt; bool _proxyEnabled; char _apIpStr[MAX_IP_STRING_LENGTH]; - void _onCaptivePortal(); - void _onDeviceInfoRequest(); - void _onNetworksRequest(); - void _onConfigRequest(); + void _onCaptivePortal(AsyncWebServerRequest *request); + void _onDeviceInfoRequest(AsyncWebServerRequest *request); + void _onNetworksRequest(AsyncWebServerRequest *request); + void _onConfigRequest(AsyncWebServerRequest *request); void _generateNetworksJson(); - void _onWifiConnectRequest(); - void _onProxyControlRequest(); - void _proxyHttpRequest(); - void _onWifiStatusRequest(); + void _onWifiConnectRequest(AsyncWebServerRequest *request); + void _onProxyControlRequest(AsyncWebServerRequest *request); + void _proxyHttpRequest(AsyncWebServerRequest *request); + void _onWifiStatusRequest(AsyncWebServerRequest *request); + + // Helpers + static void __setCORS(); + static const int MAX_POST_SIZE = 1500; + static void __parsePost(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); + static void __SendJSONError(AsyncWebServerRequest *request, String msg, int16_t code = 400); }; } // namespace HomieInternals diff --git a/src/Homie/Boot/BootNormal.cpp b/src/Homie/Boot/BootNormal.cpp index 565a2df4..0d181ba7 100644 --- a/src/Homie/Boot/BootNormal.cpp +++ b/src/Homie/Boot/BootNormal.cpp @@ -3,23 +3,24 @@ using namespace HomieInternals; BootNormal::BootNormal() -: Boot("normal") -, _mqttTimedRetry(MQTT_RECONNECT_STEP_INTERVAL, MQTT_RECONNECT_MAX_INTERVAL) -, _setupFunctionCalled(false) -, _mqttDisconnectNotified(true) -, _flaggedForOta(false) -, _flaggedForReset(false) -, _flaggedForReboot(false) -, _mqttOfflineMessageId(0) -, _otaIsBase64(false) -, _otaBase64Pads(0) -, _otaSizeTotal(0) -, _otaSizeDone(0) -, _mqttTopic(nullptr) -, _mqttClientId(nullptr) -, _mqttWillTopic(nullptr) -, _mqttPayloadBuffer(nullptr) { - _statsTimer.setInterval(STATS_SEND_INTERVAL); + : Boot("normal") + , _mqttReconnectTimer(MQTT_RECONNECT_INITIAL_INTERVAL, MQTT_RECONNECT_MAX_BACKOFF) + , _setupFunctionCalled(false) + , _mqttConnectNotified(false) + , _mqttDisconnectNotified(true) + , _otaOngoing(false) + , _flaggedForReboot(false) + , _mqttOfflineMessageId(0) + , _otaIsBase64(false) + , _otaBase64Pads(0) + , _otaSizeTotal(0) + , _otaSizeDone(0) + , _mqttTopic(nullptr) + , _mqttClientId(nullptr) + , _mqttWillTopic(nullptr) + , _mqttPayloadBuffer(nullptr) + , _mqttTopicLevels(nullptr) + , _mqttTopicLevelsCount(0) { strlcpy(_fwChecksum, ESP.getSketchMD5().c_str(), sizeof(_fwChecksum)); _fwChecksum[sizeof(_fwChecksum) - 1] = '\0'; } @@ -27,6 +28,144 @@ BootNormal::BootNormal() BootNormal::~BootNormal() { } +void BootNormal::setup() { + Boot::setup(); + + Update.runAsync(true); + + _statsTimer.setInterval(Interface::get().getConfig().get().deviceStatsInterval * 1000); + + if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_WIFI_DELAY); + + // Generate topic buffer + size_t baseTopicLength = strlen(Interface::get().getConfig().get().mqtt.baseTopic) + strlen(Interface::get().getConfig().get().deviceId); + size_t longestSubtopicLength = 29 + 1; // /$implementation/ota/firmware + for (HomieNode* iNode : HomieNode::nodes) { + size_t nodeMaxTopicLength = 1 + strlen(iNode->getId()) + 12 + 1; // /id/$properties + if (nodeMaxTopicLength > longestSubtopicLength) longestSubtopicLength = nodeMaxTopicLength; + + for (Property* iProperty : iNode->getProperties()) { + size_t propertyMaxTopicLength = 1 + strlen(iNode->getId()) + 1 + strlen(iProperty->getProperty()) + 1; + if (iProperty->isSettable()) propertyMaxTopicLength += 4; // /set + + if (propertyMaxTopicLength > longestSubtopicLength) longestSubtopicLength = propertyMaxTopicLength; + } + } + _mqttTopic = std::unique_ptr(new char[baseTopicLength + longestSubtopicLength]); + + _wifiGotIpHandler = WiFi.onStationModeGotIP(std::bind(&BootNormal::_onWifiGotIp, this, std::placeholders::_1)); + _wifiDisconnectedHandler = WiFi.onStationModeDisconnected(std::bind(&BootNormal::_onWifiDisconnected, this, std::placeholders::_1)); + + Interface::get().getMqttClient().onConnect(std::bind(&BootNormal::_onMqttConnected, this)); + Interface::get().getMqttClient().onDisconnect(std::bind(&BootNormal::_onMqttDisconnected, this, std::placeholders::_1)); + Interface::get().getMqttClient().onMessage(std::bind(&BootNormal::_onMqttMessage, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6)); + Interface::get().getMqttClient().onPublish(std::bind(&BootNormal::_onMqttPublish, this, std::placeholders::_1)); + + Interface::get().getMqttClient().setServer(Interface::get().getConfig().get().mqtt.server.host, Interface::get().getConfig().get().mqtt.server.port); + Interface::get().getMqttClient().setMaxTopicLength(MAX_MQTT_TOPIC_LENGTH); + _mqttClientId = std::unique_ptr(new char[strlen(Interface::get().brand) + 1 + strlen(Interface::get().getConfig().get().deviceId) + 1]); + strcpy(_mqttClientId.get(), Interface::get().brand); + strcat_P(_mqttClientId.get(), PSTR("-")); + strcat(_mqttClientId.get(), Interface::get().getConfig().get().deviceId); + Interface::get().getMqttClient().setClientId(_mqttClientId.get()); + char* mqttWillTopic = _prefixMqttTopic(PSTR("/$online")); + _mqttWillTopic = std::unique_ptr(new char[strlen(mqttWillTopic) + 1]); + memcpy(_mqttWillTopic.get(), mqttWillTopic, strlen(mqttWillTopic) + 1); + Interface::get().getMqttClient().setWill(_mqttWillTopic.get(), 1, true, "false"); + + if (Interface::get().getConfig().get().mqtt.auth) Interface::get().getMqttClient().setCredentials(Interface::get().getConfig().get().mqtt.username, Interface::get().getConfig().get().mqtt.password); + + ResetHandler::Attach(); + + Interface::get().getConfig().log(); + + for (HomieNode* iNode : HomieNode::nodes) { + iNode->setup(); + } + + _wifiConnect(); +} + +void BootNormal::loop() { + Boot::loop(); + + if (_flaggedForReboot && Interface::get().reset.idle) { + Interface::get().getLogger() << F("Device is idle") << endl; + + Interface::get().getLogger() << F("↻ Rebooting...") << endl; + Serial.flush(); + ESP.restart(); + } + + for (HomieNode* iNode : HomieNode::nodes) { + if (iNode->runLoopDisconnected ||Interface::get().getMqttClient().connected()) iNode->loop(); + } + if (_mqttReconnectTimer.check()) { + _mqttConnect(); + return; + } + + if (!Interface::get().getMqttClient().connected()) return; + + // here, we are connected to the broker + + if (!_advertisementProgress.done) { + _advertise(); + return; + } + + // here, we finished the advertisement + + if (!_mqttConnectNotified) { + Interface::get().ready = true; + if (Interface::get().led.enabled) Interface::get().getBlinker().stop(); + + Interface::get().getLogger() << F("✔ MQTT ready") << endl; + Interface::get().getLogger() << F("Triggering MQTT_READY event...") << endl; + Interface::get().event.type = HomieEventType::MQTT_READY; + Interface::get().eventHandler(Interface::get().event); + + for (HomieNode* iNode : HomieNode::nodes) { + iNode->onReadyToOperate(); + } + + if (!_setupFunctionCalled) { + Interface::get().getLogger() << F("Calling setup function...") << endl; + Interface::get().setupFunction(); + _setupFunctionCalled = true; + } + + _mqttConnectNotified = true; + return; + } + + // here, we have notified the sketch we are ready + + if (_mqttOfflineMessageId == 0 && Interface::get().flaggedForSleep) { + Interface::get().getLogger() << F("Device in preparation to sleep...") << endl; + _mqttOfflineMessageId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$online")), 1, true, "false"); + } + + if (_statsTimer.check()) { + uint8_t quality = Helpers::rssiToPercentage(WiFi.RSSI()); + char qualityStr[3 + 1]; + itoa(quality, qualityStr, 10); + Interface::get().getLogger() << F("〽 Sending statistics...") << endl; + Interface::get().getLogger() << F(" • Wi-Fi signal quality: ") << qualityStr << F("%") << endl; + uint16_t signalPacketId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/signal")), 1, true, qualityStr); + + _uptime.update(); + char uptimeStr[20 + 1]; + itoa(_uptime.getSeconds(), uptimeStr, 10); + Interface::get().getLogger() << F(" • Uptime: ") << uptimeStr << F("s") << endl; + uint16_t uptimePacketId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/uptime")), 1, true, uptimeStr); + + if (signalPacketId != 0 && uptimePacketId != 0) _statsTimer.tick(); + } + + Interface::get().loopFunction(); +} + void BootNormal::_prefixMqttTopic() { strcpy(_mqttTopic.get(), Interface::get().getConfig().get().mqtt.baseTopic); strcat(_mqttTopic.get(), Interface::get().getConfig().get().deviceId); @@ -39,17 +178,14 @@ char* BootNormal::_prefixMqttTopic(PGM_P topic) { return _mqttTopic.get(); } -uint16_t BootNormal::_publishOtaStatus(int status, const char* info) { +bool BootNormal::_publishOtaStatus(int status, const char* info) { String payload(status); if (info) { - payload += ' '; - payload += info; + payload.concat(F(" ")); + payload.concat(info); } - return Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/ota/status")), 1, true, payload.c_str()); -} -uint16_t BootNormal::_publishOtaStatus_P(int status, PGM_P info) { - return _publishOtaStatus(status, String(info).c_str()); + return Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/ota/status")), 0, true, payload.c_str()) != 0; } void BootNormal::_endOtaUpdate(bool success, uint8_t update_error) { @@ -69,25 +205,26 @@ void BootNormal::_endOtaUpdate(bool success, uint8_t update_error) { case UPDATE_ERROR_MAGIC_BYTE: // new firmware does not have 0xE9 in first byte case UPDATE_ERROR_NEW_FLASH_CONFIG: // bad new flash config (does not match flash ID) code = 400; // 400 Bad Request - info = PSTR("BAD_FIRMWARE"); + info.concat(F("BAD_FIRMWARE")); break; case UPDATE_ERROR_MD5: code = 400; // 400 Bad Request - info = PSTR("BAD_CHECKSUM"); + info.concat(F("BAD_CHECKSUM")); break; case UPDATE_ERROR_SPACE: code = 400; // 400 Bad Request - info = PSTR("NOT_ENOUGH_SPACE"); + info.concat(F("NOT_ENOUGH_SPACE")); break; case UPDATE_ERROR_WRITE: case UPDATE_ERROR_ERASE: case UPDATE_ERROR_READ: code = 500; // 500 Internal Server Error - info = PSTR("FLASH_ERROR"); + info.concat(F("FLASH_ERROR")); break; default: code = 500; // 500 Internal Server Error - info = PSTR("INTERNAL_ERROR ") + update_error; + info.concat(F("INTERNAL_ERROR ")); + info.concat(update_error); break; } _publishOtaStatus(code, info.c_str()); @@ -98,19 +235,48 @@ void BootNormal::_endOtaUpdate(bool success, uint8_t update_error) { Interface::get().event.type = HomieEventType::OTA_FAILED; Interface::get().eventHandler(Interface::get().event); } - _flaggedForOta = false; + _otaOngoing = false; } void BootNormal::_wifiConnect() { - if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_WIFI_DELAY); - Interface::get().getLogger() << F("↕ Attempting to connect to Wi-Fi...") << endl; - - if (WiFi.getMode() != WIFI_STA) WiFi.mode(WIFI_STA); + if (!Interface::get().disable) { + if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_WIFI_DELAY); + Interface::get().getLogger() << F("↕ Attempting to connect to Wi-Fi...") << endl; + + if (WiFi.getMode() != WIFI_STA) WiFi.mode(WIFI_STA); + + WiFi.hostname(Interface::get().getConfig().get().deviceId); + if (strcmp_P(Interface::get().getConfig().get().wifi.ip, PSTR("")) != 0) { // on _validateConfigWifi there is a requirement for mask and gateway + IPAddress convertedIp; + convertedIp.fromString(Interface::get().getConfig().get().wifi.ip); + IPAddress convertedMask; + convertedMask.fromString(Interface::get().getConfig().get().wifi.mask); + IPAddress convertedGateway; + convertedGateway.fromString(Interface::get().getConfig().get().wifi.gw); + + if (strcmp_P(Interface::get().getConfig().get().wifi.dns1, PSTR("")) != 0) { + IPAddress convertedDns1; + convertedDns1.fromString(Interface::get().getConfig().get().wifi.dns1); + if ((strcmp_P(Interface::get().getConfig().get().wifi.dns2, PSTR("")) != 0)) { // on _validateConfigWifi there is requirement that we need dns1 if we want to define dns2 + IPAddress convertedDns2; + convertedDns2.fromString(Interface::get().getConfig().get().wifi.dns2); + WiFi.config(convertedIp, convertedGateway, convertedMask, convertedDns1, convertedDns2); + } else { + WiFi.config(convertedIp, convertedGateway, convertedMask, convertedDns1); + } + } else { + WiFi.config(convertedIp, convertedGateway, convertedMask); + } + } - WiFi.hostname(Interface::get().getConfig().get().deviceId); + if (strcmp_P(Interface::get().getConfig().get().wifi.bssid, PSTR("")) != 0) { + byte bssidBytes[6]; + Helpers::stringToBytes(Interface::get().getConfig().get().wifi.bssid, ':', bssidBytes, 6, 16); + WiFi.begin(Interface::get().getConfig().get().wifi.ssid, Interface::get().getConfig().get().wifi.password, Interface::get().getConfig().get().wifi.channel, bssidBytes); + } else { + WiFi.begin(Interface::get().getConfig().get().wifi.ssid, Interface::get().getConfig().get().wifi.password); + } - if (WiFi.SSID() != Interface::get().getConfig().get().wifi.ssid || WiFi.psk() != Interface::get().getConfig().get().wifi.password) { - WiFi.begin(Interface::get().getConfig().get().wifi.ssid, Interface::get().getConfig().get().wifi.password); WiFi.setAutoConnect(true); WiFi.setAutoReconnect(true); } @@ -131,7 +297,7 @@ void BootNormal::_onWifiGotIp(const WiFiEventStationModeGotIP& event) { } void BootNormal::_onWifiDisconnected(const WiFiEventStationModeDisconnected& event) { - Interface::get().connected = false; + Interface::get().ready = false; if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_WIFI_DELAY); _statsTimer.reset(); Interface::get().getLogger() << F("✖ Wi-Fi disconnected") << endl; @@ -144,106 +310,185 @@ void BootNormal::_onWifiDisconnected(const WiFiEventStationModeDisconnected& eve } void BootNormal::_mqttConnect() { - if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_MQTT_DELAY); - Interface::get().getLogger() << F("↕ Attempting to connect to MQTT...") << endl; - Interface::get().getMqttClient().connect(); + if (!Interface::get().disable) { + if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_MQTT_DELAY); + Interface::get().getLogger() << F("↕ Attempting to connect to MQTT...") << endl; + Interface::get().getMqttClient().connect(); + } } -void BootNormal::_onMqttConnected() { - _mqttDisconnectNotified = false; - _mqttTimedRetry.deactivate(); - - Interface::get().getLogger() << F("Sending initial information...") << endl; - - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$homie")), 1, true, HOMIE_VERSION); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation")), 1, true, "esp8266"); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$mac")), 1, true, WiFi.macAddress().c_str()); - - for (HomieNode* iNode : HomieNode::nodes) { - std::unique_ptr subtopic = std::unique_ptr(new char[1 + strlen(iNode->getId()) + 12 + 1]); // /id/$properties - strcpy_P(subtopic.get(), PSTR("/")); - strcat(subtopic.get(), iNode->getId()); - strcat_P(subtopic.get(), PSTR("/$type")); - Interface::get().getMqttClient().publish(_prefixMqttTopic(subtopic.get()), 1, true, iNode->getType()); - - strcpy_P(subtopic.get(), PSTR("/")); - strcat(subtopic.get(), iNode->getId()); - strcat_P(subtopic.get(), PSTR("/$properties")); - String properties; - for (Property* iProperty : iNode->getProperties()) { - properties.concat(iProperty->getProperty()); - if (iProperty->isRange()) { - properties.concat("["); - properties.concat(iProperty->getLower()); - properties.concat("-"); - properties.concat(iProperty->getUpper()); - properties.concat("]"); +void BootNormal::_advertise() { + uint16_t packetId; + switch (_advertisementProgress.globalStep) { + case AdvertisementProgress::GlobalStep::PUB_HOMIE: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$homie")), 1, true, HOMIE_VERSION); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_NAME; + break; + case AdvertisementProgress::GlobalStep::PUB_NAME: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$name")), 1, true, Interface::get().getConfig().get().name); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_MAC; + break; + case AdvertisementProgress::GlobalStep::PUB_MAC: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$mac")), 1, true, WiFi.macAddress().c_str()); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_LOCALIP; + break; + case AdvertisementProgress::GlobalStep::PUB_LOCALIP: + { + IPAddress localIp = WiFi.localIP(); + char localIpStr[MAX_IP_STRING_LENGTH]; + Helpers::ipToString(localIp, localIpStr); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$localip")), 1, true, localIpStr); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_NODES_ATTR; + break; + } + case AdvertisementProgress::GlobalStep::PUB_NODES_ATTR: + { + String nodes; + for (HomieNode* node : HomieNode::nodes) { + nodes.concat(node->getId()); + nodes.concat(F(",")); + } + if (HomieNode::nodes.size() >= 1) nodes.remove(nodes.length() - 1); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$nodes")), 1, true, nodes.c_str()); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_STATS_INTERVAL; + break; + } + case AdvertisementProgress::GlobalStep::PUB_STATS_INTERVAL: + char statsIntervalStr[3 + 1]; + itoa(STATS_SEND_INTERVAL_SEC / 1000, statsIntervalStr, 10); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/interval")), 1, true, statsIntervalStr); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_FW_NAME; + break; + case AdvertisementProgress::GlobalStep::PUB_FW_NAME: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/name")), 1, true, Interface::get().firmware.name); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_FW_VERSION; + break; + case AdvertisementProgress::GlobalStep::PUB_FW_VERSION: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/version")), 1, true, Interface::get().firmware.version); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_FW_CHECKSUM; + break; + case AdvertisementProgress::GlobalStep::PUB_FW_CHECKSUM: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/checksum")), 1, true, _fwChecksum); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION; + break; + case AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation")), 1, true, "esp8266"); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_CONFIG; + break; + case AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_CONFIG: + { + char* safeConfigFile = Interface::get().getConfig().getSafeConfigFile(); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/config")), 1, true, safeConfigFile); + free(safeConfigFile); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_VERSION; + break; + } + case AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_VERSION: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/version")), 1, true, HOMIE_ESP8266_VERSION); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_OTA_ENABLED; + break; + case AdvertisementProgress::GlobalStep::PUB_IMPLEMENTATION_OTA_ENABLED: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/ota/enabled")), 1, true, Interface::get().getConfig().get().ota.enabled ? "true" : "false"); + if (packetId != 0) { + if (HomieNode::nodes.size()) { // skip if no nodes to publish + _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_NODES; + _advertisementProgress.nodeStep = AdvertisementProgress::NodeStep::PUB_TYPE; + _advertisementProgress.currentNodeIndex = 0; + } else { + _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_OTA; + } } - if (iProperty->isSettable()) properties.concat(":settable"); - properties.concat(","); + break; + case AdvertisementProgress::GlobalStep::PUB_NODES: + { + HomieNode* node = HomieNode::nodes[_advertisementProgress.currentNodeIndex]; + std::unique_ptr subtopic = std::unique_ptr(new char[1 + strlen(node->getId()) + 12 + 1]); // /id/$properties + switch (_advertisementProgress.nodeStep) { + case AdvertisementProgress::NodeStep::PUB_TYPE: + strcpy_P(subtopic.get(), PSTR("/")); + strcat(subtopic.get(), node->getId()); + strcat_P(subtopic.get(), PSTR("/$type")); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(subtopic.get()), 1, true, node->getType()); + if (packetId != 0) _advertisementProgress.nodeStep = AdvertisementProgress::NodeStep::PUB_PROPERTIES; + break; + case AdvertisementProgress::NodeStep::PUB_PROPERTIES: + strcpy_P(subtopic.get(), PSTR("/")); + strcat(subtopic.get(), node->getId()); + strcat_P(subtopic.get(), PSTR("/$properties")); + String properties; + for (Property* iProperty : node->getProperties()) { + properties.concat(iProperty->getProperty()); + if (iProperty->isRange()) { + properties.concat("["); + properties.concat(iProperty->getLower()); + properties.concat("-"); + properties.concat(iProperty->getUpper()); + properties.concat("]"); + } + if (iProperty->isSettable()) properties.concat(":settable"); + properties.concat(","); + } + if (node->getProperties().size() >= 1) properties.remove(properties.length() - 1); + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(subtopic.get()), 1, true, properties.c_str()); + if (packetId != 0) { + if (_advertisementProgress.currentNodeIndex < HomieNode::nodes.size() - 1) { + _advertisementProgress.currentNodeIndex++; + _advertisementProgress.nodeStep = AdvertisementProgress::NodeStep::PUB_TYPE; + } else { + _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_OTA; + } + } + break; + } + break; + } + case AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_OTA: + packetId = Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/ota/firmware/+")), 1); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_RESET; + break; + case AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_RESET: + packetId = Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/reset")), 1); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_CONFIG_SET; + break; + case AdvertisementProgress::GlobalStep::SUB_IMPLEMENTATION_CONFIG_SET: + packetId = Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/config/set")), 1); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_SET; + break; + case AdvertisementProgress::GlobalStep::SUB_SET: + packetId = Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/+/+/set")), 2); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::SUB_BROADCAST; + break; + case AdvertisementProgress::GlobalStep::SUB_BROADCAST: + { + String broadcast_topic(Interface::get().getConfig().get().mqtt.baseTopic); + broadcast_topic.concat("$broadcast/+"); + packetId = Interface::get().getMqttClient().subscribe(broadcast_topic.c_str(), 2); + if (packetId != 0) _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_ONLINE; + break; } - if (iNode->getProperties().size() >= 1) properties.remove(properties.length() - 1); - Interface::get().getMqttClient().publish(_prefixMqttTopic(subtopic.get()), 1, true, properties.c_str()); + case AdvertisementProgress::GlobalStep::PUB_ONLINE: + packetId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$online")), 1, true, "true"); + if (packetId != 0) _advertisementProgress.done = true; + break; } +} - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$name")), 1, true, Interface::get().getConfig().get().name); - - IPAddress localIp = WiFi.localIP(); - char localIpStr[MAX_IP_STRING_LENGTH]; - snprintf(localIpStr, MAX_IP_STRING_LENGTH - 1, "%d.%d.%d.%d", localIp[0], localIp[1], localIp[2], localIp[3]); - - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$localip")), 1, true, localIpStr); - - char statsIntervalStr[3 + 1]; - itoa(STATS_SEND_INTERVAL / 1000, statsIntervalStr, 10); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/interval")), 1, true, statsIntervalStr); - - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/name")), 1, true, Interface::get().firmware.name); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/version")), 1, true, Interface::get().firmware.version); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$fw/checksum")), 1, true, _fwChecksum); - - Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/+/+/set")), 2); - - /* Implementation specific */ - - char* safeConfigFile = Interface::get().getConfig().getSafeConfigFile(); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/config")), 1, true, safeConfigFile); - free(safeConfigFile); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/version")), 1, true, HOMIE_ESP8266_VERSION); - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/ota/enabled")), 1, true, Interface::get().getConfig().get().ota.enabled ? "true" : "false"); - Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/ota/firmware")), 0); - Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/ota/checksum")), 0); - Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/reset")), 2); - Interface::get().getMqttClient().subscribe(_prefixMqttTopic(PSTR("/$implementation/config/set")), 2); - - /** Euphi: TODO #142: Homie $broadcast */ - String broadcast_topic(Interface::get().getConfig().get().mqtt.baseTopic); - broadcast_topic.concat("$broadcast/+"); - Interface::get().getMqttClient().subscribe(broadcast_topic.c_str(), 2); - - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$online")), 1, true, "true"); - - Interface::get().connected = true; - if (Interface::get().led.enabled) Interface::get().getBlinker().stop(); - - Interface::get().getLogger() << F("✔ MQTT ready") << endl; - Interface::get().getLogger() << F("Triggering MQTT_CONNECTED event...") << endl; - Interface::get().event.type = HomieEventType::MQTT_CONNECTED; - Interface::get().eventHandler(Interface::get().event); +void BootNormal::_onMqttConnected() { + _mqttDisconnectNotified = false; + _mqttReconnectTimer.deactivate(); - for (HomieNode* iNode : HomieNode::nodes) { - iNode->onReadyToOperate(); - } + Interface::get().getLogger() << F("Sending initial information...") << endl; - if (!_setupFunctionCalled) { - Interface::get().getLogger() << F("Calling setup function...") << endl; - Interface::get().setupFunction(); - _setupFunctionCalled = true; - } + _advertise(); } void BootNormal::_onMqttDisconnected(AsyncMqttClientDisconnectReason reason) { - Interface::get().connected = false; + Interface::get().ready = false; + _mqttConnectNotified = false; + _advertisementProgress.done = false; + _advertisementProgress.globalStep = AdvertisementProgress::GlobalStep::PUB_HOMIE; + _advertisementProgress.nodeStep = AdvertisementProgress::NodeStep::PUB_TYPE; + _advertisementProgress.currentNodeIndex = 0; if (!_mqttDisconnectNotified) { _statsTimer.reset(); Interface::get().getLogger() << F("✖ MQTT disconnected") << endl; @@ -254,9 +499,8 @@ void BootNormal::_onMqttDisconnected(AsyncMqttClientDisconnectReason reason) { _mqttDisconnectNotified = true; - if (_mqttOfflineMessageId != 0) { + if (Interface::get().flaggedForSleep) { _mqttOfflineMessageId = 0; - Interface::get().flaggedForSleep = false; Interface::get().getLogger() << F("Triggering READY_TO_SLEEP event...") << endl; Interface::get().event.type = HomieEventType::READY_TO_SLEEP; Interface::get().eventHandler(Interface::get().event); @@ -267,235 +511,317 @@ void BootNormal::_onMqttDisconnected(AsyncMqttClientDisconnectReason reason) { _mqttConnect(); } else { - _mqttTimedRetry.activate(); + _mqttReconnectTimer.activate(); } } void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total) { if (total == 0) return; // no empty message possible - HomieRange range; - range.isRange = false; - range.index = 0; - - // Check for Broadcast first (it does not contain device-id) - char* broadcast_topic = topic + strlen(Interface::get().getConfig().get().mqtt.baseTopic); - // Skip devices/${id}/ --- +1 for / - char* device_topic = broadcast_topic + strlen(Interface::get().getConfig().get().deviceId) + 1; + // split topic on each "/" + if (index == 0) { + __splitTopic(topic); + } // 1. Handle OTA firmware (not copied to payload buffer) - if (strcmp_P(device_topic, PSTR("$implementation/ota/firmware")) == 0) { // If this is the OTA firmware - if (!Interface::get().getConfig().get().ota.enabled) { - if (index == 0) { + if (__handleOTAUpdates(topic, payload, properties, len, index, total)) + return; + + // 2. Fill Payload Buffer + if (__fillPayloadBuffer(topic, payload, properties, len, index, total)) + return; + + /* Arrived here, the payload is complete */ + + // 3. handle broadcasts + if (__handleBroadcasts(topic, payload, properties, len, index, total)) + return; + + // 4.all following messages are only for this deviceId + if (strcmp(_mqttTopicLevels.get()[0], Interface::get().getConfig().get().deviceId) != 0) + return; + + // 5. handle reset + if (__handleResets(topic, payload, properties, len, index, total)) + return; + + // 6. handle config set + if (__handleConfig(topic, payload, properties, len, index, total)) + return; + + // 7. here, we're sure we have a node property + if (__handleNodeProperty(topic, payload, properties, len, index, total)) + return; +} + +void BootNormal::_onMqttPublish(uint16_t id) { + Interface::get().event.type = HomieEventType::MQTT_PACKET_ACKNOWLEDGED; + Interface::get().event.packetId = id; + Interface::get().eventHandler(Interface::get().event); + + if (Interface::get().flaggedForSleep && id == _mqttOfflineMessageId) { + Interface::get().getLogger() << F("Offline message acknowledged. Disconnecting MQTT...") << endl; + Interface::get().getMqttClient().disconnect(); + } +} + +// _onMqttMessage Helpers + +void BootNormal::__splitTopic(char* topic) { + // split topic on each "/" + char* afterBaseTopic = topic + strlen(Interface::get().getConfig().get().mqtt.baseTopic); + + uint8_t topicLevelsCount = 1; + for (uint8_t i = 0; i < strlen(afterBaseTopic); i++) { + if (afterBaseTopic[i] == '/') topicLevelsCount++; + } + + _mqttTopicLevels = std::unique_ptr(new char*[topicLevelsCount]); + _mqttTopicLevelsCount = topicLevelsCount; + + const char* delimiter = "/"; + uint8_t topicLevelIndex = 0; + + char* token = strtok(afterBaseTopic, delimiter); + while (token != nullptr) { + _mqttTopicLevels[topicLevelIndex++] = token; + + token = strtok(nullptr, delimiter); + } +} + +bool HomieInternals::BootNormal::__fillPayloadBuffer(char * topic, char * payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total) { + // Reallocate Buffer everytime a new message is received + if (_mqttPayloadBuffer == nullptr || index == 0) _mqttPayloadBuffer = std::unique_ptr(new char[total + 1]); + + // copy payload into buffer + memcpy(_mqttPayloadBuffer.get() + index, payload, len); + + // return if payload buffer is not complete + if (index + len != total) + return true; + // terminate buffer + _mqttPayloadBuffer.get()[total] = '\0'; + return false; +} + +bool HomieInternals::BootNormal::__handleOTAUpdates(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total) { + if ( + _mqttTopicLevelsCount == 5 + && strcmp(_mqttTopicLevels.get()[0], Interface::get().getConfig().get().deviceId) == 0 + && strcmp_P(_mqttTopicLevels.get()[1], PSTR("$implementation")) == 0 + && strcmp_P(_mqttTopicLevels.get()[2], PSTR("ota")) == 0 + && strcmp_P(_mqttTopicLevels.get()[3], PSTR("firmware")) == 0 + ) { + if (index == 0) { + Interface::get().getLogger() << F("Receiving OTA payload") << endl; + if (!Interface::get().getConfig().get().ota.enabled) { _publishOtaStatus(403); // 403 Forbidden + Interface::get().getLogger() << F("✖ Aborting, OTA not enabled") << endl; + return true; } - } else if (!_flaggedForOta) { - if (index == 0) { - Interface::get().getLogger() << F("Receiving OTA firmware but not requested, skipping...") << endl; - _publishOtaStatus(400, PSTR("NOT_REQUESTED")); - } - } else { - if (index == 0) { + + char* firmwareMd5 = _mqttTopicLevels.get()[4]; + if (!Helpers::validateMd5(firmwareMd5)) { + _endOtaUpdate(false, UPDATE_ERROR_MD5); + Interface::get().getLogger() << F("✖ Aborting, invalid MD5") << endl; + return true; + } else if (strcmp(firmwareMd5, _fwChecksum) == 0) { + _publishOtaStatus(304); // 304 Not Modified + Interface::get().getLogger() << F("✖ Aborting, firmware is the same") << endl; + return true; + } else { + Update.setMD5(firmwareMd5); + _publishOtaStatus(202); + _otaOngoing = true; + Interface::get().getLogger() << F("↕ OTA started") << endl; Interface::get().getLogger() << F("Triggering OTA_STARTED event...") << endl; Interface::get().event.type = HomieEventType::OTA_STARTED; Interface::get().eventHandler(Interface::get().event); + } + } else if (!_otaOngoing) { + return true; // we've not validated the checksum + } - // Autodetect if firmware is binary or base64-encoded. ESP firmware always has a magic first byte 0xE9. - if (*payload == 0xE9) { - _otaIsBase64 = false; - Interface::get().getLogger() << F("Firmware is binary") << endl; - } else { - // Base64-decode first two bytes. Compare decoded value against magic byte. - char plain[2]; // need 12 bits - base64_init_decodestate(&_otaBase64State); - int l = base64_decode_block(payload, 2, plain, &_otaBase64State); - if ((l == 1) && (plain[0] == 0xE9)) { - _otaIsBase64 = true; - _otaBase64Pads = 0; - Interface::get().getLogger() << F("Firmware is base64-encoded") << endl; - if (total % 4) { - // Base64 encoded length not a multiple of 4 bytes - _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); - return; - } + // here, we need to flash the payload - // Restart base64-decoder - base64_init_decodestate(&_otaBase64State); - } else { - // Bad firmware format + if (index == 0) { + // Autodetect if firmware is binary or base64-encoded. ESP firmware always has a magic first byte 0xE9. + if (*payload == 0xE9) { + _otaIsBase64 = false; + Interface::get().getLogger() << F("Firmware is binary") << endl; + } else { + // Base64-decode first two bytes. Compare decoded value against magic byte. + char plain[2]; // need 12 bits + base64_init_decodestate(&_otaBase64State); + int l = base64_decode_block(payload, 2, plain, &_otaBase64State); + if ((l == 1) && (plain[0] == 0xE9)) { + _otaIsBase64 = true; + _otaBase64Pads = 0; + Interface::get().getLogger() << F("Firmware is base64-encoded") << endl; + if (total % 4) { + // Base64 encoded length not a multiple of 4 bytes _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); - return; + return true; } - } - _otaSizeDone = 0; - _otaSizeTotal = _otaIsBase64 ? base64_decode_expected_len(total) : total; - bool success = Update.begin(_otaSizeTotal); - if (!success) { - // Detected error during begin (e.g. size == 0 or size > space) - _endOtaUpdate(false, Update.getError()); - return; + + // Restart base64-decoder + base64_init_decodestate(&_otaBase64State); + } else { + // Bad firmware format + _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); + return true; } } + _otaSizeDone = 0; + _otaSizeTotal = _otaIsBase64 ? base64_decode_expected_len(total) : total; + bool success = Update.begin(_otaSizeTotal); + if (!success) { + // Detected error during begin (e.g. size == 0 or size > space) + _endOtaUpdate(false, Update.getError()); + return true; + } + } - size_t write_len; - if (_otaIsBase64) { - // Base64-firmware: Make sure there are no non-base64 characters in the payload. - // libb64/cdecode.c doesn't ignore such characters if the compiler treats `char` - // as `unsigned char`. - size_t bin_len = 0; - char* p = payload; - for (size_t i = 0; i < len; i ++) { - char c = *p++; - bool b64 = ((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z')) || ((c >= '0') && (c <= '9')) || (c == '+') || (c == '/'); - if (b64) { - bin_len++; - } else if (c == '=') { - // Ignore "=" padding (but only at the end and only up to 2) - if (index + i < total - 2) { - _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); - return; - } - // Note the number of pad characters at the end - _otaBase64Pads++; - } else { - // Non-base64 character in firmware + size_t write_len; + if (_otaIsBase64) { + // Base64-firmware: Make sure there are no non-base64 characters in the payload. + // libb64/cdecode.c doesn't ignore such characters if the compiler treats `char` + // as `unsigned char`. + size_t bin_len = 0; + char* p = payload; + for (size_t i = 0; i < len; i++) { + char c = *p++; + bool b64 = ((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z')) || ((c >= '0') && (c <= '9')) || (c == '+') || (c == '/'); + if (b64) { + bin_len++; + } else if (c == '=') { + // Ignore "=" padding (but only at the end and only up to 2) + if (index + i < total - 2) { _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); - return; - } - } - if (bin_len > 0) { - // Decode base64 payload in-place. base64_decode_block() can decode in-place, - // except for the first two base64-characters which make one binary byte plus - // 4 extra bits (saved in _otaBase64State). So we "manually" decode the first - // two characters into a temporary buffer and manually merge that back into - // the payload. This one is a little tricky, but it saves us from having to - // dynamically allocate some 800 bytes of memory for every payload chunk. - size_t dec_len = bin_len > 1 ? 2 : 1; - char c; - write_len = (size_t) base64_decode_block(payload, dec_len, &c, &_otaBase64State); - *payload = c; - - if (bin_len > 1) { - write_len += (size_t) base64_decode_block((const char*) payload + dec_len, bin_len - dec_len, payload + write_len, &_otaBase64State); + return true; } + // Note the number of pad characters at the end + _otaBase64Pads++; } else { - write_len = 0; + // Non-base64 character in firmware + _endOtaUpdate(false, UPDATE_ERROR_MAGIC_BYTE); + return true; } - } else { - // Binary firmware - write_len = len; } - if (write_len > 0) { - bool success = Update.write(reinterpret_cast(payload), write_len) > 0; - if (success) { - // Flash write successful. - _otaSizeDone += write_len; - if (_otaIsBase64 && (index + len == total)) { - // Having received the last chunk of base64 encoded firmware, we can now determine - // the real size of the binary firmware from the number of padding character ("="): - // If we have received 1 pad character, real firmware size modulo 3 was 2. - // If we have received 2 pad characters, real firmware size modulo 3 was 1. - // Correct the total firmware length accordingly. - _otaSizeTotal -= _otaBase64Pads; - } - - String progress(_otaSizeDone); - progress += F("/"); - progress += _otaSizeTotal; - Interface::get().getLogger() << F("Receiving OTA firmware (") << progress << F(")...") << endl; - _publishOtaStatus(206, progress.c_str()); // 206 Partial Content - - // Done with the update? - if (index + len == total) { - // With base64-coded firmware, we may have provided a length off by one or two - // to Update.begin() because the base64-coded firmware may use padding (one or - // two "=") at the end. In case of base64, total length was adjusted above. - // Check the real length here and ask Update::end() to skip this test. - if ((_otaIsBase64) && (_otaSizeDone != _otaSizeTotal)) { - _endOtaUpdate(false, UPDATE_ERROR_SIZE); - return; - } - success = Update.end(_otaIsBase64); - _endOtaUpdate(success, Update.getError()); - } - } else { - // Error erasing or writing flash - _endOtaUpdate(false, Update.getError()); + if (bin_len > 0) { + // Decode base64 payload in-place. base64_decode_block() can decode in-place, + // except for the first two base64-characters which make one binary byte plus + // 4 extra bits (saved in _otaBase64State). So we "manually" decode the first + // two characters into a temporary buffer and manually merge that back into + // the payload. This one is a little tricky, but it saves us from having to + // dynamically allocate some 800 bytes of memory for every payload chunk. + size_t dec_len = bin_len > 1 ? 2 : 1; + char c; + write_len = (size_t)base64_decode_block(payload, dec_len, &c, &_otaBase64State); + *payload = c; + + if (bin_len > 1) { + write_len += (size_t)base64_decode_block((const char*)payload + dec_len, bin_len - dec_len, payload + write_len, &_otaBase64State); } + } else { + write_len = 0; } + } else { + // Binary firmware + write_len = len; } - return; - } - - // 2. Fill Payload Buffer - - // Reallocate Buffer everytime a new message is received - if (_mqttPayloadBuffer == nullptr || index == 0) _mqttPayloadBuffer = std::unique_ptr(new char[total + 1]); - - // TODO(euphi): Check if buffer size matches payload length - memcpy(_mqttPayloadBuffer.get() + index, payload, len); + if (write_len > 0) { + bool success = Update.write(reinterpret_cast(payload), write_len) > 0; + if (success) { + // Flash write successful. + _otaSizeDone += write_len; + if (_otaIsBase64 && (index + len == total)) { + // Having received the last chunk of base64 encoded firmware, we can now determine + // the real size of the binary firmware from the number of padding character ("="): + // If we have received 1 pad character, real firmware size modulo 3 was 2. + // If we have received 2 pad characters, real firmware size modulo 3 was 1. + // Correct the total firmware length accordingly. + _otaSizeTotal -= _otaBase64Pads; + } - if (index + len != total) return; // return if payload buffer is not complete - _mqttPayloadBuffer.get()[total] = '\0'; + String progress(_otaSizeDone); + progress.concat(F("/")); + progress.concat(_otaSizeTotal); + Interface::get().getLogger() << F("Receiving OTA firmware (") << progress << F(")...") << endl; - /* Arrived here, the payload is complete */ - - if (strcmp_P(device_topic, PSTR("$implementation/ota/checksum")) == 0) { // If this is the MD5 OTA checksum (32 hex characters) - Interface::get().getLogger() << F("✴ OTA available (checksum ") << _mqttPayloadBuffer.get() << F(")") << endl; - if (!Interface::get().getConfig().get().ota.enabled) { - _publishOtaStatus(403); // 403 Forbidden - } else if (strcmp(_mqttPayloadBuffer.get(), _fwChecksum) == 0) { - _publishOtaStatus(304); // 304 Not Modified - } else { - // 32 hex characters? - if (strlen(_mqttPayloadBuffer.get()) != 32) { - // Invalid MD5 number => 400 BAD_CHECKSUM - _endOtaUpdate(false, UPDATE_ERROR_MD5); - return; - } + Interface::get().event.type = HomieEventType::OTA_PROGRESS; + Interface::get().event.sizeDone = _otaSizeDone; + Interface::get().event.sizeTotal = _otaSizeTotal; + Interface::get().eventHandler(Interface::get().event); - for (uint8_t i = 0; i < 32; i++) { - char c = _mqttPayloadBuffer.get()[i]; - bool valid = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); - if (!valid) { - _endOtaUpdate(false, UPDATE_ERROR_MD5); - return; + _publishOtaStatus(206, progress.c_str()); // 206 Partial Content + + // Done with the update? + if (index + len == total) { + // With base64-coded firmware, we may have provided a length off by one or two + // to Update.begin() because the base64-coded firmware may use padding (one or + // two "=") at the end. In case of base64, total length was adjusted above. + // Check the real length here and ask Update::end() to skip this test. + if ((_otaIsBase64) && (_otaSizeDone != _otaSizeTotal)) { + _endOtaUpdate(false, UPDATE_ERROR_SIZE); + return true; + } + success = Update.end(_otaIsBase64); + _endOtaUpdate(success, Update.getError()); } + } else { + // Error erasing or writing flash + _endOtaUpdate(false, Update.getError()); } - - _flaggedForOta = true; - Update.setMD5(_mqttPayloadBuffer.get()); - _publishOtaStatus(202); } - return; + return true; } + return false; +} - // 3. Special Functions: $broadcast - /** TODO(euphi): Homie $broadcast */ - if (strncmp(broadcast_topic, "$broadcast", 10) == 0) { - broadcast_topic += sizeof("$broadcast"); // move pointer to second char after $broadcast (sizeof counts the \0) - String broadcastLevel(broadcast_topic); +bool HomieInternals::BootNormal::__handleBroadcasts(char * topic, char * payload, const AsyncMqttClientMessageProperties & properties, size_t len, size_t index, size_t total) { + if ( + _mqttTopicLevelsCount == 2 + && strcmp_P(_mqttTopicLevels.get()[0], PSTR("$broadcast")) == 0 + ) { + String broadcastLevel(_mqttTopicLevels.get()[1]); Interface::get().getLogger() << F("📢 Calling broadcast handler...") << endl; bool handled = Interface::get().broadcastHandler(broadcastLevel, _mqttPayloadBuffer.get()); if (!handled) { Interface::get().getLogger() << F("The following broadcast was not handled:") << endl; - Interface::get().getLogger() << F(" • Level: ") << broadcastLevel << endl; + Interface::get().getLogger() << F(" • Level: ") << broadcastLevel << endl; Interface::get().getLogger() << F(" • Value: ") << _mqttPayloadBuffer.get() << endl; } - return; + return true; } + return false; +} - // 4. Special Functions: $reset - if (strcmp_P(device_topic, PSTR("$implementation/reset")) == 0 && strcmp(_mqttPayloadBuffer.get(), "true") == 0) { +bool HomieInternals::BootNormal::__handleResets(char * topic, char * payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total) { + if ( + _mqttTopicLevelsCount == 3 + && strcmp_P(_mqttTopicLevels.get()[1], PSTR("$implementation")) == 0 + && strcmp_P(_mqttTopicLevels.get()[2], PSTR("reset")) == 0 + && strcmp_P(_mqttPayloadBuffer.get(), PSTR("true")) == 0 + ) { Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/reset")), 1, true, "false"); - _flaggedForReset = true; Interface::get().getLogger() << F("Flagged for reset by network") << endl; - return; + Interface::get().disable = true; + Interface::get().reset.resetFlag = true; + return true; } + return false; +} - // 5. Special Functions set $config - if (strcmp_P(device_topic, PSTR("$implementation/config/set")) == 0) { +bool HomieInternals::BootNormal::__handleConfig(char * topic, char * payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total) { + if ( + _mqttTopicLevelsCount == 4 + && strcmp_P(_mqttTopicLevels.get()[1], PSTR("$implementation")) == 0 + && strcmp_P(_mqttTopicLevels.get()[2], PSTR("config")) == 0 + && strcmp_P(_mqttTopicLevels.get()[3], PSTR("set")) == 0 + ) { Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$implementation/config/set")), 1, true, ""); if (Interface::get().getConfig().patch(_mqttPayloadBuffer.get())) { Interface::get().getLogger() << F("✔ Configuration updated") << endl; @@ -504,36 +830,29 @@ void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessa } else { Interface::get().getLogger() << F("✖ Configuration not updated") << endl; } - return; + return true; } + return false; +} - // 6. Determine specific Node - // Determine if message for our deviceid // [Issue #243] - const char* messageDeviceId = Interface::get().getConfig().get().deviceId; - for (uint16_t i = 0; i < strlen(messageDeviceId); i++) { - if ((broadcast_topic[i] != messageDeviceId[i]) || (broadcast_topic[i] == '/' && messageDeviceId[i] != '\0')) { - return; - } - } +bool HomieInternals::BootNormal::__handleNodeProperty(char * topic, char * payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total) { + // initialize HomieRange + HomieRange range; + range.isRange = false; + range.index = 0; - // Implicit node properties - device_topic[strlen(device_topic) - 4] = '\0'; // Remove /set - uint16_t separator = 0; - for (uint16_t i = 0; i < strlen(device_topic); i++) { - if (device_topic[i] == '/') { - separator = i; - break; - } - } - char* node = device_topic; - node[separator] = '\0'; - char* property = device_topic + separator + 1; + char* node = _mqttTopicLevels.get()[1]; + char* property = _mqttTopicLevels.get()[2]; HomieNode* homieNode = HomieNode::find(node); if (!homieNode) { Interface::get().getLogger() << F("Node ") << node << F(" not registered") << endl; - return; + return true; } +#ifdef DEBUG + Interface::get().getLogger() << F("Recived network message for ") << homieNode->getId() << endl; +#endif // DEBUG + int16_t rangeSeparator = -1; for (uint16_t i = 0; i < strlen(property); i++) { if (property[i] == '_') { @@ -549,7 +868,7 @@ void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessa for (uint8_t i = 0; i < rangeIndexTest.length(); i++) { if (!isDigit(rangeIndexTest.charAt(i))) { Interface::get().getLogger() << F("Range index ") << rangeIndexStr << F(" is not valid") << endl; - return; + return true; } } range.index = rangeIndexTest.toInt(); @@ -564,7 +883,7 @@ void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessa break; } else { Interface::get().getLogger() << F("Range index ") << range.index << F(" is not within the bounds of ") << property << endl; - return; + return true; } } } else if (strcmp(property, iProperty->getProperty()) == 0) { @@ -575,18 +894,24 @@ void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessa if (!propertyObject || !propertyObject->isSettable()) { Interface::get().getLogger() << F("Node ") << node << F(": ") << property << F(" property not settable") << endl; - return; + return true; } +#ifdef DEBUG Interface::get().getLogger() << F("Calling global input handler...") << endl; +#endif // DEBUG bool handled = Interface::get().globalInputHandler(*homieNode, String(property), range, String(_mqttPayloadBuffer.get())); - if (handled) return; + if (handled) return true; +#ifdef DEBUG Interface::get().getLogger() << F("Calling node input handler...") << endl; +#endif // DEBUG handled = homieNode->handleInput(String(property), range, String(_mqttPayloadBuffer.get())); - if (handled) return; + if (handled) return true; +#ifdef DEBUG Interface::get().getLogger() << F("Calling property input handler...") << endl; +#endif // DEBUG handled = propertyObject->getInputHandler()(range, String(_mqttPayloadBuffer.get())); if (!handled) { @@ -601,154 +926,6 @@ void BootNormal::_onMqttMessage(char* topic, char* payload, AsyncMqttClientMessa } Interface::get().getLogger() << F(" • Value: ") << _mqttPayloadBuffer.get() << endl; } -} - -void BootNormal::_onMqttPublish(uint16_t id) { - Interface::get().event.type = HomieEventType::MQTT_PACKET_ACKNOWLEDGED; - Interface::get().event.packetId = id; - Interface::get().eventHandler(Interface::get().event); - - if (Interface::get().flaggedForSleep && id == _mqttOfflineMessageId) { - Interface::get().getLogger() << F("Offline message acknowledged. Disconnecting MQTT...") << endl; - Interface::get().getMqttClient().disconnect(); - } -} - -void BootNormal::_handleReset() { - if (Interface::get().reset.enabled) { - _resetDebouncer.update(); - - if (_resetDebouncer.read() == Interface::get().reset.triggerState) { - _flaggedForReset = true; - Interface::get().getLogger() << F("Flagged for reset by pin") << endl; - } - } - - if (Interface::get().reset.flaggedBySketch) { - _flaggedForReset = true; - Interface::get().getLogger() << F("Flagged for reset by sketch") << endl; - } -} - -void BootNormal::setup() { - Boot::setup(); - - Update.runAsync(true); - - if (Interface::get().led.enabled) Interface::get().getBlinker().start(LED_WIFI_DELAY); - - // Generate topic buffer - size_t baseTopicLength = strlen(Interface::get().getConfig().get().mqtt.baseTopic) + strlen(Interface::get().getConfig().get().deviceId); - size_t longestSubtopicLength = 29 + 1; // /$implementation/ota/firmware - for (HomieNode* iNode : HomieNode::nodes) { - size_t nodeMaxTopicLength = 1 + strlen(iNode->getId()) + 12 + 1; // /id/$properties - if (nodeMaxTopicLength > longestSubtopicLength) longestSubtopicLength = nodeMaxTopicLength; - - for (Property* iProperty : iNode->getProperties()) { - size_t propertyMaxTopicLength = 1 + strlen(iNode->getId()) + 1 + strlen(iProperty->getProperty()) + 1; - if (iProperty->isSettable()) propertyMaxTopicLength += 4; // /set - - if (propertyMaxTopicLength > longestSubtopicLength) longestSubtopicLength = propertyMaxTopicLength; - } - } - _mqttTopic = std::unique_ptr(new char[baseTopicLength + longestSubtopicLength]); - - _wifiGotIpHandler = WiFi.onStationModeGotIP(std::bind(&BootNormal::_onWifiGotIp, this, std::placeholders::_1)); - _wifiDisconnectedHandler = WiFi.onStationModeDisconnected(std::bind(&BootNormal::_onWifiDisconnected, this, std::placeholders::_1)); - Interface::get().getMqttClient().onConnect(std::bind(&BootNormal::_onMqttConnected, this)); - Interface::get().getMqttClient().onDisconnect(std::bind(&BootNormal::_onMqttDisconnected, this, std::placeholders::_1)); - Interface::get().getMqttClient().onMessage(std::bind(&BootNormal::_onMqttMessage, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5, std::placeholders::_6)); - Interface::get().getMqttClient().onPublish(std::bind(&BootNormal::_onMqttPublish, this, std::placeholders::_1)); - - Interface::get().getMqttClient().setServer(Interface::get().getConfig().get().mqtt.server.host, Interface::get().getConfig().get().mqtt.server.port); - Interface::get().getMqttClient().setKeepAlive(10).setMaxTopicLength(MAX_MQTT_TOPIC_LENGTH); - _mqttClientId = std::unique_ptr(new char[strlen(Interface::get().brand) + 1 + strlen(Interface::get().getConfig().get().deviceId) + 1]); - strcpy(_mqttClientId.get(), Interface::get().brand); - strcat_P(_mqttClientId.get(), PSTR("-")); - strcat(_mqttClientId.get(), Interface::get().getConfig().get().deviceId); - Interface::get().getMqttClient().setClientId(_mqttClientId.get()); - char* mqttWillTopic = _prefixMqttTopic(PSTR("/$online")); - _mqttWillTopic = std::unique_ptr(new char[strlen(mqttWillTopic) + 1]); - memcpy(_mqttWillTopic.get(), mqttWillTopic, strlen(mqttWillTopic) + 1); - Interface::get().getMqttClient().setWill(_mqttWillTopic.get(), 1, true, "false"); - - if (Interface::get().getConfig().get().mqtt.auth) Interface::get().getMqttClient().setCredentials(Interface::get().getConfig().get().mqtt.username, Interface::get().getConfig().get().mqtt.password); - - - if (Interface::get().reset.enabled) { - pinMode(Interface::get().reset.triggerPin, INPUT_PULLUP); - - _resetDebouncer.attach(Interface::get().reset.triggerPin); - _resetDebouncer.interval(Interface::get().reset.triggerTime); - } - - Interface::get().getConfig().log(); - - for (HomieNode* iNode : HomieNode::nodes) { - iNode->setup(); - } - - _wifiConnect(); -} - -void BootNormal::loop() { - Boot::loop(); - - _handleReset(); - - if (_mqttTimedRetry.check()) { - _mqttConnect(); - } - - if (_flaggedForReset && Interface::get().reset.idle) { - Interface::get().getLogger() << F("Device is idle") << endl; - Interface::get().getConfig().erase(); - Interface::get().getLogger() << F("Configuration erased") << endl; - - Interface::get().getLogger() << F("Triggering ABOUT_TO_RESET event...") << endl; - Interface::get().event.type = HomieEventType::ABOUT_TO_RESET; - Interface::get().eventHandler(Interface::get().event); - - Interface::get().getLogger() << F("↻ Rebooting into config mode...") << endl; - Serial.flush(); - ESP.restart(); - } - - if (_flaggedForReboot && Interface::get().reset.idle) { - Interface::get().getLogger() << F("Device is idle") << endl; - - Interface::get().getLogger() << F("↻ Rebooting...") << endl; - Serial.flush(); - ESP.restart(); - } - - if (Interface::get().connected) { - if (_mqttOfflineMessageId == 0 && Interface::get().flaggedForSleep) { - Interface::get().getLogger() << F("Device in preparation to sleep...") << endl; - _mqttOfflineMessageId = Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$online")), 1, true, "false"); - } - - if (_statsTimer.check()) { - uint8_t quality = Helpers::rssiToPercentage(WiFi.RSSI()); - char qualityStr[3 + 1]; - itoa(quality, qualityStr, 10); - Interface::get().getLogger() << F("〽 Sending statistics...") << endl; - Interface::get().getLogger() << F(" • Wi-Fi signal quality: ") << qualityStr << F("%") << endl; - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/signal")), 1, true, qualityStr); - - _uptime.update(); - char uptimeStr[20 + 1]; - itoa(_uptime.getSeconds(), uptimeStr, 10); - Interface::get().getLogger() << F(" • Uptime: ") << uptimeStr << F("s") << endl; - Interface::get().getMqttClient().publish(_prefixMqttTopic(PSTR("/$stats/uptime")), 1, true, uptimeStr); - _statsTimer.tick(); - } - - Interface::get().loopFunction(); - - for (HomieNode* iNode : HomieNode::nodes) { - iNode->loop(); - } - } + return false; } diff --git a/src/Homie/Boot/BootNormal.hpp b/src/Homie/Boot/BootNormal.hpp index 1f2ba41d..8d119715 100644 --- a/src/Homie/Boot/BootNormal.hpp +++ b/src/Homie/Boot/BootNormal.hpp @@ -7,7 +7,6 @@ #include #include #include -#include #include "../../HomieNode.hpp" #include "../../HomieRange.hpp" #include "../../StreamingOperator.hpp" @@ -17,8 +16,9 @@ #include "../Utils/Helpers.hpp" #include "../Uptime.hpp" #include "../Timer.hpp" -#include "../TimedRetry.hpp" +#include "../ExponentialBackoffTimer.hpp" #include "Boot.hpp" +#include "../Utils/ResetHandler.hpp" namespace HomieInternals { class BootNormal : public Boot { @@ -29,17 +29,48 @@ class BootNormal : public Boot { void loop(); private: + struct AdvertisementProgress { + bool done = false; + enum class GlobalStep { + PUB_HOMIE, + PUB_NAME, + PUB_MAC, + PUB_LOCALIP, + PUB_NODES_ATTR, + PUB_STATS_INTERVAL, + PUB_FW_NAME, + PUB_FW_VERSION, + PUB_FW_CHECKSUM, + PUB_IMPLEMENTATION, + PUB_IMPLEMENTATION_CONFIG, + PUB_IMPLEMENTATION_VERSION, + PUB_IMPLEMENTATION_OTA_ENABLED, + PUB_NODES, + SUB_IMPLEMENTATION_OTA, + SUB_IMPLEMENTATION_RESET, + SUB_IMPLEMENTATION_CONFIG_SET, + SUB_SET, + SUB_BROADCAST, + PUB_ONLINE + } globalStep; + + enum class NodeStep { + PUB_TYPE, + PUB_PROPERTIES + } nodeStep; + + size_t currentNodeIndex; + } _advertisementProgress; Uptime _uptime; Timer _statsTimer; - TimedRetry _mqttTimedRetry; + ExponentialBackoffTimer _mqttReconnectTimer; bool _setupFunctionCalled; WiFiEventHandler _wifiGotIpHandler; WiFiEventHandler _wifiDisconnectedHandler; + bool _mqttConnectNotified; bool _mqttDisconnectNotified; - bool _flaggedForOta; - bool _flaggedForReset; + bool _otaOngoing; bool _flaggedForReboot; - Bounce _resetDebouncer; uint16_t _mqttOfflineMessageId; char _fwChecksum[32 + 1]; bool _otaIsBase64; @@ -53,20 +84,30 @@ class BootNormal : public Boot { std::unique_ptr _mqttClientId; std::unique_ptr _mqttWillTopic; std::unique_ptr _mqttPayloadBuffer; + std::unique_ptr _mqttTopicLevels; + uint8_t _mqttTopicLevelsCount; - void _handleReset(); void _wifiConnect(); void _onWifiGotIp(const WiFiEventStationModeGotIP& event); void _onWifiDisconnected(const WiFiEventStationModeDisconnected& event); void _mqttConnect(); + void _advertise(); void _onMqttConnected(); void _onMqttDisconnected(AsyncMqttClientDisconnectReason reason); void _onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total); void _onMqttPublish(uint16_t id); void _prefixMqttTopic(); char* _prefixMqttTopic(PGM_P topic); - uint16_t _publishOtaStatus(int status, const char* info = nullptr); - uint16_t _publishOtaStatus_P(int status, PGM_P info); + bool _publishOtaStatus(int status, const char* info = nullptr); void _endOtaUpdate(bool success, uint8_t update_error = UPDATE_ERROR_OK); + + // _onMqttMessage Helpers + void __splitTopic(char* topic); + bool __fillPayloadBuffer(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); + bool __handleOTAUpdates(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); + bool __handleBroadcasts(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); + bool __handleResets(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); + bool __handleConfig(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); + bool __handleNodeProperty(char* topic, char* payload, const AsyncMqttClientMessageProperties& properties, size_t len, size_t index, size_t total); }; } // namespace HomieInternals diff --git a/src/Homie/Boot/BootStandalone.cpp b/src/Homie/Boot/BootStandalone.cpp index 9ac8f473..bd326782 100644 --- a/src/Homie/Boot/BootStandalone.cpp +++ b/src/Homie/Boot/BootStandalone.cpp @@ -3,57 +3,20 @@ using namespace HomieInternals; BootStandalone::BootStandalone() -: Boot("standalone") -, _flaggedForConfig(false) { + : Boot("standalone") { } BootStandalone::~BootStandalone() { } -void BootStandalone::_handleReset() { - if (Interface::get().reset.enabled) { - _resetDebouncer.update(); - - if (_resetDebouncer.read() == Interface::get().reset.triggerState) { - _flaggedForConfig = true; - Interface::get().getLogger() << F("Flagged for configuration mode by pin") << endl; - } - } - - if (Interface::get().reset.flaggedBySketch) { - _flaggedForConfig = true; - Interface::get().getLogger() << F("Flagged for configuration mode by sketch") << endl; - } -} - void BootStandalone::setup() { Boot::setup(); WiFi.mode(WIFI_OFF); - if (Interface::get().reset.enabled) { - pinMode(Interface::get().reset.triggerPin, INPUT_PULLUP); - - _resetDebouncer.attach(Interface::get().reset.triggerPin); - _resetDebouncer.interval(Interface::get().reset.triggerTime); - } + ResetHandler::Attach(); } void BootStandalone::loop() { Boot::loop(); - - _handleReset(); - - if (_flaggedForConfig && Interface::get().reset.idle) { - Interface::get().getLogger() << F("Device is idle") << endl; - Interface::get().getConfig().setHomieBootModeOnNextBoot(HomieBootMode::CONFIGURATION); - - Interface::get().getLogger() << F("Triggering ABOUT_TO_RESET event...") << endl; - Interface::get().event.type = HomieEventType::ABOUT_TO_RESET; - Interface::get().eventHandler(Interface::get().event); - - Interface::get().getLogger() << F("↻ Rebooting into config mode...") << endl; - Serial.flush(); - ESP.restart(); - } } diff --git a/src/Homie/Boot/BootStandalone.hpp b/src/Homie/Boot/BootStandalone.hpp index 8c9c7815..2d3f24ea 100644 --- a/src/Homie/Boot/BootStandalone.hpp +++ b/src/Homie/Boot/BootStandalone.hpp @@ -2,9 +2,9 @@ #include "Arduino.h" -#include -#include "../../StreamingOperator.hpp" #include "Boot.hpp" +#include "../../StreamingOperator.hpp" +#include "../Utils/ResetHandler.hpp" namespace HomieInternals { class BootStandalone : public Boot { @@ -13,11 +13,5 @@ class BootStandalone : public Boot { ~BootStandalone(); void setup(); void loop(); - - private: - bool _flaggedForConfig; - Bounce _resetDebouncer; - - void _handleReset(); }; } // namespace HomieInternals diff --git a/src/Homie/Config.cpp b/src/Homie/Config.cpp index 61f1e12e..0c6d5465 100644 --- a/src/Homie/Config.cpp +++ b/src/Homie/Config.cpp @@ -3,9 +3,9 @@ using namespace HomieInternals; Config::Config() -: _configStruct() -, _spiffsBegan(false) -, _valid(false) { + : _configStruct() + , _spiffsBegan(false) + , _valid(false) { } bool Config::_spiffsBegin() { @@ -67,6 +67,40 @@ bool Config::load() { if (parsedJson.containsKey("device_id")) { reqDeviceId = parsedJson["device_id"]; } + uint16_t regDeviceStatsInterval = STATS_SEND_INTERVAL_SEC; //device_stats_interval + if (parsedJson.containsKey(F("device_stats_interval"))) { + regDeviceStatsInterval = parsedJson[F("device_stats_interval")]; + } + + const char* reqWifiBssid = ""; + if (parsedJson["wifi"].as().containsKey("bssid")) { + reqWifiBssid = parsedJson["wifi"]["bssid"]; + } + uint16_t reqWifiChannel = 0; + if (parsedJson["wifi"].as().containsKey("channel")) { + reqWifiChannel = parsedJson["wifi"]["channel"]; + } + const char* reqWifiIp = ""; + if (parsedJson["wifi"].as().containsKey("ip")) { + reqWifiIp = parsedJson["wifi"]["ip"]; + } + const char* reqWifiMask = ""; + if (parsedJson["wifi"].as().containsKey("mask")) { + reqWifiMask = parsedJson["wifi"]["mask"]; + } + const char* reqWifiGw = ""; + if (parsedJson["wifi"].as().containsKey("gw")) { + reqWifiGw = parsedJson["wifi"]["gw"]; + } + const char* reqWifiDns1 = ""; + if (parsedJson["wifi"].as().containsKey("dns1")) { + reqWifiDns1 = parsedJson["wifi"]["dns1"]; + } + const char* reqWifiDns2 = ""; + if (parsedJson["wifi"].as().containsKey("dns2")) { + reqWifiDns2 = parsedJson["wifi"]["dns2"]; + } + uint16_t reqMqttPort = DEFAULT_MQTT_PORT; if (parsedJson["mqtt"].as().containsKey("port")) { reqMqttPort = parsedJson["mqtt"]["port"]; @@ -94,9 +128,17 @@ bool Config::load() { } strlcpy(_configStruct.name, reqName, MAX_FRIENDLY_NAME_LENGTH); + strlcpy(_configStruct.deviceId, reqDeviceId, MAX_DEVICE_ID_LENGTH); + _configStruct.deviceStatsInterval = regDeviceStatsInterval; strlcpy(_configStruct.wifi.ssid, reqWifiSsid, MAX_WIFI_SSID_LENGTH); if (reqWifiPassword) strlcpy(_configStruct.wifi.password, reqWifiPassword, MAX_WIFI_PASSWORD_LENGTH); - strlcpy(_configStruct.deviceId, reqDeviceId, MAX_DEVICE_ID_LENGTH); + strlcpy(_configStruct.wifi.bssid, reqWifiBssid, MAX_MAC_STRING_LENGTH + 6); + _configStruct.wifi.channel = reqWifiChannel; + strlcpy(_configStruct.wifi.ip, reqWifiIp, MAX_IP_STRING_LENGTH); + strlcpy(_configStruct.wifi.gw, reqWifiGw, MAX_IP_STRING_LENGTH); + strlcpy(_configStruct.wifi.mask, reqWifiMask, MAX_IP_STRING_LENGTH); + strlcpy(_configStruct.wifi.dns1, reqWifiDns1, MAX_IP_STRING_LENGTH); + strlcpy(_configStruct.wifi.dns2, reqWifiDns2, MAX_IP_STRING_LENGTH); strlcpy(_configStruct.mqtt.server.host, reqMqttHost, MAX_HOSTNAME_LENGTH); _configStruct.mqtt.server.port = reqMqttPort; strlcpy(_configStruct.mqtt.baseTopic, reqMqttBaseTopic, MAX_MQTT_BASE_TOPIC_LENGTH); @@ -217,60 +259,62 @@ void Config::write(const JsonObject& config) { } bool Config::patch(const char* patch) { - if (!_spiffsBegin()) { return false; } + if (!_spiffsBegin()) { return false; } - StaticJsonBuffer patchJsonBuffer; - JsonObject& patchObject = patchJsonBuffer.parseObject(patch); + StaticJsonBuffer patchJsonBuffer; + JsonObject& patchObject = patchJsonBuffer.parseObject(patch); - if (!patchObject.success()) { - Interface::get().getLogger() << F("✖ Invalid or too big JSON") << endl; - return false; - } + if (!patchObject.success()) { + Interface::get().getLogger() << F("✖ Invalid or too big JSON") << endl; + return false; + } - File configFile = SPIFFS.open(CONFIG_FILE_PATH, "r"); - if (!configFile) { - Interface::get().getLogger() << F("✖ Cannot open config file") << endl; - return false; - } + File configFile = SPIFFS.open(CONFIG_FILE_PATH, "r"); + if (!configFile) { + Interface::get().getLogger() << F("✖ Cannot open config file") << endl; + return false; + } - size_t configSize = configFile.size(); - - char configJson[MAX_JSON_CONFIG_FILE_SIZE]; - configFile.readBytes(configJson, configSize); - configFile.close(); - configJson[configSize] = '\0'; - - StaticJsonBuffer configJsonBuffer; - JsonObject& configObject = configJsonBuffer.parseObject(configJson); - - for (JsonObject::iterator it = patchObject.begin(); it != patchObject.end(); ++it) { - if (patchObject[it->key].is()) { - JsonObject& subObject = patchObject[it->key].as(); - for (JsonObject::iterator it2 = subObject.begin(); it2 != subObject.end(); ++it2) { - if (!configObject.containsKey(it->key) || !configObject[it->key].is()) { - String error = "✖ Config does not contain a "; - error.concat(it->key); - error.concat(" object"); - Interface::get().getLogger() << error << endl; - return false; - } - JsonObject& subConfigObject = configObject[it->key].as(); - subConfigObject[it2->key] = it2->value; + size_t configSize = configFile.size(); + + char configJson[MAX_JSON_CONFIG_FILE_SIZE]; + configFile.readBytes(configJson, configSize); + configFile.close(); + configJson[configSize] = '\0'; + + StaticJsonBuffer configJsonBuffer; + JsonObject& configObject = configJsonBuffer.parseObject(configJson); + + // To do alow object that dont currently exist to be added like settings. + // if settings wasnt there origionally then it should be allowed to be added by incremental. + for (JsonObject::iterator it = patchObject.begin(); it != patchObject.end(); ++it) { + if (patchObject[it->key].is()) { + JsonObject& subObject = patchObject[it->key].as(); + for (JsonObject::iterator it2 = subObject.begin(); it2 != subObject.end(); ++it2) { + if (!configObject.containsKey(it->key) || !configObject[it->key].is()) { + String error = "✖ Config does not contain a "; + error.concat(it->key); + error.concat(" object"); + Interface::get().getLogger() << error << endl; + return false; } - } else { - configObject[it->key] = it->value; + JsonObject& subConfigObject = configObject[it->key].as(); + subConfigObject[it2->key] = it2->value; } + } else { + configObject[it->key] = it->value; } + } - ConfigValidationResult configValidationResult = Validation::validateConfig(configObject); - if (!configValidationResult.valid) { - Interface::get().getLogger() << F("✖ Config file is not valid, reason: ") << configValidationResult.reason << endl; - return false; - } + ConfigValidationResult configValidationResult = Validation::validateConfig(configObject); + if (!configValidationResult.valid) { + Interface::get().getLogger() << F("✖ Config file is not valid, reason: ") << configValidationResult.reason << endl; + return false; + } - write(configObject); + write(configObject); - return true; + return true; } bool Config::isValid() const { @@ -282,11 +326,16 @@ void Config::log() const { Interface::get().getLogger() << F(" • Hardware device ID: ") << DeviceId::get() << endl; Interface::get().getLogger() << F(" • Device ID: ") << _configStruct.deviceId << endl; Interface::get().getLogger() << F(" • Name: ") << _configStruct.name << endl; + Interface::get().getLogger() << F(" • Device Stats Interval: ") << _configStruct.deviceStatsInterval << F(" sec") << endl; Interface::get().getLogger() << F(" • Wi-Fi: ") << endl; Interface::get().getLogger() << F(" ◦ SSID: ") << _configStruct.wifi.ssid << endl; Interface::get().getLogger() << F(" ◦ Password not shown") << endl; - + if (strcmp_P(_configStruct.wifi.ip, PSTR("")) != 0) { + Interface::get().getLogger() << F(" ◦ IP: ") << _configStruct.wifi.ip << endl; + Interface::get().getLogger() << F(" ◦ Mask: ") << _configStruct.wifi.mask << endl; + Interface::get().getLogger() << F(" ◦ Gateway: ") << _configStruct.wifi.gw << endl; + } Interface::get().getLogger() << F(" • MQTT: ") << endl; Interface::get().getLogger() << F(" ◦ Host: ") << _configStruct.mqtt.server.host << endl; Interface::get().getLogger() << F(" ◦ Port: ") << _configStruct.mqtt.server.port << endl; @@ -299,4 +348,27 @@ void Config::log() const { Interface::get().getLogger() << F(" • OTA: ") << endl; Interface::get().getLogger() << F(" ◦ Enabled? ") << (_configStruct.ota.enabled ? F("yes") : F("no")) << endl; + + if (IHomieSetting::settings.size() > 0) { + Interface::get().getLogger() << F(" • Custom settings: ") << endl; + for (IHomieSetting* iSetting : IHomieSetting::settings) { + Interface::get().getLogger() << F(" ◦ "); + + if (iSetting->isBool()) { + HomieSetting* setting = static_cast*>(iSetting); + Interface::get().getLogger() << setting->getName() << F(": ") << setting->get() << F(" (") << (setting->wasProvided() ? F("set") : F("default")) << F(")"); + } else if (iSetting->isLong()) { + HomieSetting* setting = static_cast*>(iSetting); + Interface::get().getLogger() << setting->getName() << F(": ") << setting->get() << F(" (") << (setting->wasProvided() ? F("set") : F("default")) << F(")"); + } else if (iSetting->isDouble()) { + HomieSetting* setting = static_cast*>(iSetting); + Interface::get().getLogger() << setting->getName() << F(": ") << setting->get() << F(" (") << (setting->wasProvided() ? F("set") : F("default")) << F(")"); + } else if (iSetting->isConstChar()) { + HomieSetting* setting = static_cast*>(iSetting); + Interface::get().getLogger() << setting->getName() << F(": ") << setting->get() << F(" (") << (setting->wasProvided() ? F("set") : F("default")) << F(")"); + } + + Interface::get().getLogger() << endl; + } + } } diff --git a/src/Homie/Constants.hpp b/src/Homie/Constants.hpp index 6fe2702b..5b26686b 100644 --- a/src/Homie/Constants.hpp +++ b/src/Homie/Constants.hpp @@ -3,7 +3,7 @@ #include namespace HomieInternals { - const char HOMIE_VERSION[] = "2.0.0"; + const char HOMIE_VERSION[] = "2.0.1"; const char HOMIE_ESP8266_VERSION[] = "2.0.0"; const IPAddress ACCESS_POINT_IP(192, 168, 123, 1); @@ -18,9 +18,9 @@ namespace HomieInternals { const char DEFAULT_BRAND[] = "Homie"; const uint16_t CONFIG_SCAN_INTERVAL = 20 * 1000; - const uint32_t STATS_SEND_INTERVAL = 1 * 60 * 1000; - const uint32_t MQTT_RECONNECT_STEP_INTERVAL = 250; - const uint32_t MQTT_RECONNECT_MAX_INTERVAL = 60000; + const uint32_t STATS_SEND_INTERVAL_SEC = 1 * 60; + const uint16_t MQTT_RECONNECT_INITIAL_INTERVAL = 1000; + const uint8_t MQTT_RECONNECT_MAX_BACKOFF = 6; const float LED_WIFI_DELAY = 1; const float LED_MQTT_DELAY = 0.2; diff --git a/src/Homie/Datatypes/ConfigStruct.hpp b/src/Homie/Datatypes/ConfigStruct.hpp index ed013be6..5d68c638 100644 --- a/src/Homie/Datatypes/ConfigStruct.hpp +++ b/src/Homie/Datatypes/ConfigStruct.hpp @@ -7,10 +7,18 @@ namespace HomieInternals { struct ConfigStruct { char name[MAX_FRIENDLY_NAME_LENGTH]; char deviceId[MAX_DEVICE_ID_LENGTH]; + uint16_t deviceStatsInterval; struct WiFi { char ssid[MAX_WIFI_SSID_LENGTH]; char password[MAX_WIFI_PASSWORD_LENGTH]; + char bssid[MAX_MAC_STRING_LENGTH + 6]; + uint16_t channel; + char ip[MAX_IP_STRING_LENGTH]; + char mask[MAX_IP_STRING_LENGTH]; + char gw[MAX_IP_STRING_LENGTH]; + char dns1[MAX_IP_STRING_LENGTH]; + char dns2[MAX_IP_STRING_LENGTH]; } wifi; struct MQTT { diff --git a/src/Homie/Datatypes/Interface.cpp b/src/Homie/Datatypes/Interface.cpp index 49d3f87a..290ce923 100644 --- a/src/Homie/Datatypes/Interface.cpp +++ b/src/Homie/Datatypes/Interface.cpp @@ -5,19 +5,21 @@ using namespace HomieInternals; InterfaceData Interface::_interface; // need to define the static variable InterfaceData::InterfaceData() -: brand{'\0'} -, bootMode{HomieBootMode::UNDEFINED} -, configurationAp { .secured = false, .password = {'\0'} } -, firmware { .name = {'\0'}, .version = {'\0'} } -, led { .enabled = false, .pin = 0, .on = 0 } -, reset { .enabled = false, .idle = false, .triggerPin = 0, .triggerState = 0, .triggerTime = 0, .flaggedBySketch = false } -, flaggedForSleep{false} -, connected{false} -, _logger{nullptr} -, _blinker{nullptr} -, _config{nullptr} -, _mqttClient{nullptr} -, _sendingPromise{nullptr} { + : brand{ '\0' } + , bootMode{ HomieBootMode::UNDEFINED } + , configurationAp{ .secured = false, .password = {'\0'} } + , firmware{ .name = {'\0'}, .version = {'\0'} } + , led{ .enabled = false, .pin = 0, .on = 0 } + , reset{ .enabled = false, .idle = false, .triggerPin = 0, .triggerState = 0, .triggerTime = 0, .resetFlag = false } + , disable{ false } + , flaggedForSleep{ false } + , event{} + , ready{ false } + , _logger{ nullptr } + , _blinker{ nullptr } + , _config{ nullptr } + , _mqttClient{ nullptr } + , _sendingPromise{ nullptr } { } InterfaceData& Interface::get() { diff --git a/src/Homie/Datatypes/Interface.hpp b/src/Homie/Datatypes/Interface.hpp index 3570cd1e..3e67a788 100644 --- a/src/Homie/Datatypes/Interface.hpp +++ b/src/Homie/Datatypes/Interface.hpp @@ -18,6 +18,7 @@ class Blinker; class Config; class SendingPromise; class HomieClass; + class InterfaceData { friend HomieClass; @@ -51,9 +52,10 @@ class InterfaceData { uint8_t triggerPin; uint8_t triggerState; uint16_t triggerTime; - bool flaggedBySketch; + bool resetFlag; } reset; + bool disable; bool flaggedForSleep; GlobalInputHandler globalInputHandler; @@ -64,7 +66,7 @@ class InterfaceData { /***** Runtime data *****/ HomieEvent event; - bool connected; + bool ready; Logger& getLogger() { return *_logger; } Blinker& getBlinker() { return *_blinker; } Config& getConfig() { return *_config; } diff --git a/src/Homie/ExponentialBackoffTimer.cpp b/src/Homie/ExponentialBackoffTimer.cpp new file mode 100644 index 00000000..d4cf6fd3 --- /dev/null +++ b/src/Homie/ExponentialBackoffTimer.cpp @@ -0,0 +1,42 @@ +#include "ExponentialBackoffTimer.hpp" + +using namespace HomieInternals; + +ExponentialBackoffTimer::ExponentialBackoffTimer(uint16_t initialInterval, uint8_t maxBackoff) +: _timer(Timer()) +, _initialInterval(initialInterval) +, _maxBackoff(maxBackoff) +, _retryCount(0) { + _timer.deactivate(); +} + +bool ExponentialBackoffTimer::check() { + if (_timer.check()) { + if (_retryCount != _maxBackoff) _retryCount++; + + uint32_t fixedDelay = pow(_retryCount, 2) * _initialInterval; + uint32_t randomDifference = random(0, (fixedDelay / 10) + 1); + uint32_t nextInterval = fixedDelay - randomDifference; + + _timer.setInterval(nextInterval, false); + return true; + } else { + return false; + } +} + +void ExponentialBackoffTimer::activate() { + if (_timer.isActive()) return; + + _timer.setInterval(_initialInterval, false); + _timer.activate(); + _retryCount = 1; +} + +void ExponentialBackoffTimer::deactivate() { + _timer.deactivate(); +} + +bool ExponentialBackoffTimer::isActive() const { + return _timer.isActive(); +} diff --git a/src/Homie/ExponentialBackoffTimer.hpp b/src/Homie/ExponentialBackoffTimer.hpp new file mode 100644 index 00000000..5d7d251d --- /dev/null +++ b/src/Homie/ExponentialBackoffTimer.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "Timer.hpp" +#include "Datatypes/Interface.hpp" + +namespace HomieInternals { +class ExponentialBackoffTimer { + public: + ExponentialBackoffTimer(uint16_t initialInterval, uint8_t maxBackoff); + void activate(); + bool check(); + void deactivate(); + bool isActive() const; + + private: + Timer _timer; + + uint16_t _initialInterval; + uint8_t _maxBackoff; + uint8_t _retryCount; +}; +} // namespace HomieInternals diff --git a/src/Homie/Limits.hpp b/src/Homie/Limits.hpp index 0c0e1373..e4686dc2 100644 --- a/src/Homie/Limits.hpp +++ b/src/Homie/Limits.hpp @@ -4,28 +4,32 @@ namespace HomieInternals { const uint16_t MAX_JSON_CONFIG_FILE_SIZE = 1000; - const uint16_t MAX_JSON_CONFIG_ARDUINOJSON_BUFFER_SIZE = JSON_OBJECT_SIZE(6) + JSON_OBJECT_SIZE(2) + JSON_OBJECT_SIZE(6) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(10); // Max 5 elements at root, 2 elements in nested, etc... the last 10 means 10 custom settings max + + // max setting elements + const uint8_t MAX_CONFIG_SETTING_SIZE = 10; + // 6 elements at root, 9 elements at wifi, 6 elements at mqtt, 1 element at ota, max settings elements + const uint16_t MAX_JSON_CONFIG_ARDUINOJSON_BUFFER_SIZE = JSON_OBJECT_SIZE(6) + JSON_OBJECT_SIZE(9) + JSON_OBJECT_SIZE(6) + JSON_OBJECT_SIZE(1) + JSON_OBJECT_SIZE(MAX_CONFIG_SETTING_SIZE); const uint8_t MAX_WIFI_SSID_LENGTH = 32 + 1; const uint8_t MAX_WIFI_PASSWORD_LENGTH = 64 + 1; const uint16_t MAX_HOSTNAME_LENGTH = 255 + 1; const uint8_t MAX_MQTT_CREDS_LENGTH = 32 + 1; - const uint8_t MAX_MQTT_BASE_TOPIC_LENGTH = sizeof("shared-broker/username-lolipop/homie/sensors/"); - const uint8_t MAX_MQTT_TOPIC_LENGTH = 128; + const uint8_t MAX_MQTT_BASE_TOPIC_LENGTH = 48 + 1; + const uint8_t MAX_MQTT_TOPIC_LENGTH = 128 + 1; - const uint8_t MAX_FRIENDLY_NAME_LENGTH = sizeof("My awesome friendly name of the living room"); - const uint8_t MAX_DEVICE_ID_LENGTH = sizeof("my-awesome-device-id-living-room"); + const uint8_t MAX_FRIENDLY_NAME_LENGTH = 64 + 1; + const uint8_t MAX_DEVICE_ID_LENGTH = 32 + 1; - const uint8_t MAX_BRAND_LENGTH = MAX_WIFI_SSID_LENGTH - sizeof("-0123abcd") + 1; - const uint8_t MAX_FIRMWARE_NAME_LENGTH = sizeof("my-awesome-home-firmware-name"); - const uint8_t MAX_FIRMWARE_VERSION_LENGTH = sizeof("v1.0.0-alpha+001"); + const uint8_t MAX_BRAND_LENGTH = MAX_WIFI_SSID_LENGTH - 10 - 1; + const uint8_t MAX_FIRMWARE_NAME_LENGTH = 32 + 1; + const uint8_t MAX_FIRMWARE_VERSION_LENGTH = 16 + 1; - const uint8_t MAX_NODE_ID_LENGTH = sizeof("my-super-awesome-node-id"); - const uint8_t MAX_NODE_TYPE_LENGTH = sizeof("my-super-awesome-type"); - const uint8_t MAX_NODE_PROPERTY_LENGTH = sizeof("my-super-awesome-property"); + const uint8_t MAX_NODE_ID_LENGTH = 24 + 1; + const uint8_t MAX_NODE_TYPE_LENGTH = 24 + 1; + const uint8_t MAX_NODE_PROPERTY_LENGTH = 24 + 1; - const uint8_t MAX_IP_STRING_LENGTH = sizeof("123.123.123.123"); + const uint8_t MAX_IP_STRING_LENGTH = 16 + 1; const uint8_t MAX_MAC_STRING_LENGTH = 12; } // namespace HomieInternals diff --git a/src/Homie/Logger.cpp b/src/Homie/Logger.cpp index d3fc70e6..e1291637 100644 --- a/src/Homie/Logger.cpp +++ b/src/Homie/Logger.cpp @@ -16,9 +16,11 @@ void Logger::setPrinter(Print* printer) { } size_t Logger::write(uint8_t character) { - if (_loggingEnabled) _printer->write(character); + if (_loggingEnabled) return _printer->write(character); + return 0; } size_t Logger::write(const uint8_t* buffer, size_t size) { - if (_loggingEnabled) _printer->write(buffer, size); + if (_loggingEnabled) return _printer->write(buffer, size); + return 0; } diff --git a/src/Homie/Strings.hpp b/src/Homie/Strings.hpp index 99806064..aa8c8a21 100644 --- a/src/Homie/Strings.hpp +++ b/src/Homie/Strings.hpp @@ -3,8 +3,9 @@ namespace HomieInternals { // config mode - const char PROGMEM_CONFIG_CORS[] PROGMEM = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: PUT\r\nAccess-Control-Allow-Headers: Content-Type, Origin, Referer, User-Agent\r\n\r\n"; const char PROGMEM_CONFIG_APPLICATION_JSON[] PROGMEM = "application/json"; + const char PROGMEM_CONFIG_JSON_SUCCESS[] PROGMEM = "{\"success\":true}"; const char PROGMEM_CONFIG_JSON_FAILURE_BEGINNING[] PROGMEM = "{\"success\":false,\"error\":\""; - const char PROGMEM_CONFIG_NETWORKS_FAILURE[] PROGMEM = "{\"error\": \"Initial Wi-Fi scan not finished yet\"}"; + const char PROGMEM_CONFIG_JSON_FAILURE_END[] PROGMEM = "\"}"; + } diff --git a/src/Homie/TimedRetry.cpp b/src/Homie/TimedRetry.cpp deleted file mode 100644 index b11bebcf..00000000 --- a/src/Homie/TimedRetry.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include "TimedRetry.hpp" -#include "Datatypes/Interface.hpp" - -using namespace HomieInternals; - -TimedRetry::TimedRetry(uint32_t stepInterval, uint32_t maxInterval) -: _currentStep(0) -, _stepInterval(stepInterval) -, _maxInterval(maxInterval) -, _timer(Timer()) { - _timer.deactivate(); -} - -void TimedRetry::activate() { - if (!_timer.isActive()) { - _timer.setInterval(_stepInterval, false); - _timer.activate(); - _currentStep = 1; - } -} - -bool TimedRetry::check() { - if (_timer.check()) { - long nextInterval = (_currentStep*2)*_stepInterval; - if (nextInterval <= _maxInterval) { - _currentStep = _currentStep*2; - } else { - nextInterval = _maxInterval; - } - // setInterval does tick() - _timer.setInterval(nextInterval, false); - Interface::get().getLogger() << F("Retrying (") << nextInterval << F("ms)") << F("...") << endl; - return true; - - } else { - return false; - } -} - -void TimedRetry::deactivate() { - _timer.deactivate(); - _timer.reset(); -} - -bool TimedRetry::isActive() { - return _timer.isActive(); -} diff --git a/src/Homie/TimedRetry.hpp b/src/Homie/TimedRetry.hpp deleted file mode 100644 index 9e62b384..00000000 --- a/src/Homie/TimedRetry.hpp +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "Timer.hpp" - -namespace HomieInternals { -class TimedRetry { - public: - TimedRetry(uint32_t stepInterval, uint32_t maxInterval); - void activate(); - bool check(); - void deactivate(); - bool isActive(); - - private: - uint32_t _currentStep; - uint32_t _stepInterval; - uint32_t _maxInterval; - Timer _timer; -}; -} // namespace HomieInternals diff --git a/src/Homie/Timer.cpp b/src/Homie/Timer.cpp index ce1b9c09..8e13519a 100644 --- a/src/Homie/Timer.cpp +++ b/src/Homie/Timer.cpp @@ -16,11 +16,16 @@ void Timer::setInterval(uint32_t interval, bool tickAtBeginning) { this->reset(); } +uint32_t HomieInternals::Timer::getInterval() { + return _interval; +} + bool Timer::check() const { - if (_active) { - if (_tickAtBeginning && _initialTime == 0) return true; - if (millis() - _initialTime >= _interval) return true; - } + if (!_active) return false; + + if (_tickAtBeginning && _initialTime == 0) return true; + if (millis() - _initialTime >= _interval) return true; + return false; } diff --git a/src/Homie/Uptime.cpp b/src/Homie/Uptime.cpp index 710578f2..2365b275 100644 --- a/src/Homie/Uptime.cpp +++ b/src/Homie/Uptime.cpp @@ -3,16 +3,16 @@ using namespace HomieInternals; Uptime::Uptime() -: _seconds(0) +: _milliseconds(0) , _lastTick(0) { } void Uptime::update() { uint32_t now = millis(); - _seconds += (now - _lastTick) / 1000UL; + _milliseconds += (now - _lastTick); _lastTick = now; } uint64_t Uptime::getSeconds() const { - return _seconds; + return (_milliseconds / 1000ULL); } diff --git a/src/Homie/Uptime.hpp b/src/Homie/Uptime.hpp index 3289153d..a43efd33 100644 --- a/src/Homie/Uptime.hpp +++ b/src/Homie/Uptime.hpp @@ -10,7 +10,7 @@ class Uptime { uint64_t getSeconds() const; private: - uint64_t _seconds; + uint64_t _milliseconds; uint32_t _lastTick; }; } // namespace HomieInternals diff --git a/src/Homie/Utils/Helpers.cpp b/src/Homie/Utils/Helpers.cpp index cda87896..8c228680 100644 --- a/src/Homie/Utils/Helpers.cpp +++ b/src/Homie/Utils/Helpers.cpp @@ -2,6 +2,13 @@ using namespace HomieInternals; +void Helpers::abort(const String& message) { + Serial.begin(115200); + Serial << message << endl; + Serial.flush(); + ::abort(); +} + uint8_t Helpers::rssiToPercentage(int32_t rssi) { uint8_t quality; if (rssi <= -100) { @@ -15,6 +22,54 @@ uint8_t Helpers::rssiToPercentage(int32_t rssi) { return quality; } +void Helpers::stringToBytes(const char* str, char sep, byte* bytes, int maxBytes, int base) { + // taken from http://stackoverflow.com/a/35236734 + for (int i = 0; i < maxBytes; i++) { + bytes[i] = strtoul(str, NULL, base); + str = strchr(str, sep); + if (str == NULL || *str == '\0') { + break; + } + str++; + } +} + +bool Helpers::validateIP(const char* ip) { + IPAddress test; + return test.fromString(ip); +} + +bool Helpers::validateMacAddress(const char* mac) { + // taken from http://stackoverflow.com/a/4792211 + int i = 0; + int s = 0; + while (*mac) { + if (isxdigit(*mac)) { + i++; + } else if (*mac == ':' || *mac == '-') { + if (i == 0 || i / 2 - 1 != s) + break; + ++s; + } else { + s = -1; + } + ++mac; + } + return (i == MAX_MAC_STRING_LENGTH && s == 5); +} + +bool Helpers::validateMd5(const char* md5) { + if (strlen(md5) != 32) return false; + + for (uint8_t i = 0; i < 32; i++) { + char c = md5[i]; + bool valid = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + if (!valid) return false; + } + + return true; +} + std::unique_ptr Helpers::cloneString(const String& string) { size_t length = string.length(); std::unique_ptr copy(new char[length + 1]); @@ -23,3 +78,7 @@ std::unique_ptr Helpers::cloneString(const String& string) { return copy; } + +void Helpers::ipToString(const IPAddress& ip, char * str) { + snprintf(str, MAX_IP_STRING_LENGTH, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); +} diff --git a/src/Homie/Utils/Helpers.hpp b/src/Homie/Utils/Helpers.hpp index e9162894..1bf949cc 100644 --- a/src/Homie/Utils/Helpers.hpp +++ b/src/Homie/Utils/Helpers.hpp @@ -1,13 +1,21 @@ #pragma once #include "Arduino.h" - +#include +#include "../../StreamingOperator.hpp" +#include "../Limits.hpp" #include namespace HomieInternals { class Helpers { public: + static void abort(const String& message); static uint8_t rssiToPercentage(int32_t rssi); + static void stringToBytes(const char* str, char sep, byte* bytes, int maxBytes, int base); + static bool validateIP(const char* ip); + static bool validateMacAddress(const char* mac); + static bool validateMd5(const char* md5); static std::unique_ptr cloneString(const String& string); + static void ipToString(const IPAddress& ip, char* str); }; } // namespace HomieInternals diff --git a/src/Homie/Utils/ResetHandler.cpp b/src/Homie/Utils/ResetHandler.cpp new file mode 100644 index 00000000..f4fd1c11 --- /dev/null +++ b/src/Homie/Utils/ResetHandler.cpp @@ -0,0 +1,51 @@ +#include "ResetHandler.hpp" + +using namespace HomieInternals; + +Ticker ResetHandler::_resetBTNTicker; +Bounce ResetHandler::_resetBTNDebouncer; +Ticker ResetHandler::_resetTicker; +bool ResetHandler::_sentReset = false; + +void ResetHandler::Attach() { + if (Interface::get().reset.enabled) { + pinMode(Interface::get().reset.triggerPin, INPUT_PULLUP); + _resetBTNDebouncer.attach(Interface::get().reset.triggerPin); + _resetBTNDebouncer.interval(Interface::get().reset.triggerTime); + + _resetBTNTicker.attach_ms(10, _tick); + _resetTicker.attach_ms(100, _handleReset); + } +} + +void ResetHandler::_tick() { + if (!Interface::get().reset.resetFlag && Interface::get().reset.enabled) { + _resetBTNDebouncer.update(); + if (_resetBTNDebouncer.read() == Interface::get().reset.triggerState) { + Interface::get().getLogger() << F("Flagged for reset by pin") << endl; + Interface::get().disable = true; + Interface::get().reset.resetFlag = true; + } + } +} + +void ResetHandler::_handleReset() { + if (Interface::get().reset.resetFlag && !_sentReset && Interface::get().reset.idle) { + Interface::get().getLogger() << F("Device is idle") << endl; + + Interface::get().getConfig().erase(); + Interface::get().getLogger() << F("Configuration erased") << endl; + + // Set boot mode + Interface::get().getConfig().setHomieBootModeOnNextBoot(HomieBootMode::CONFIGURATION); + + Interface::get().getLogger() << F("Triggering ABOUT_TO_RESET event...") << endl; + Interface::get().event.type = HomieEventType::ABOUT_TO_RESET; + Interface::get().eventHandler(Interface::get().event); + + Interface::get().getLogger() << F("↻ Rebooting into config mode...") << endl; + Serial.flush(); + ESP.restart(); + _sentReset = true; + } +} diff --git a/src/Homie/Utils/ResetHandler.hpp b/src/Homie/Utils/ResetHandler.hpp new file mode 100644 index 00000000..8ece92b5 --- /dev/null +++ b/src/Homie/Utils/ResetHandler.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Arduino.h" + +#include +#include +#include "../../StreamingOperator.hpp" +#include "../Datatypes/Interface.hpp" + +namespace HomieInternals { +class ResetHandler { + public: + static void Attach(); + + private: + // Disallow creating an instance of this object + ResetHandler() {} + static Ticker _resetBTNTicker; + static Bounce _resetBTNDebouncer; + static void _tick(); + static Ticker _resetTicker; + static bool _sentReset; + static void _handleReset(); +}; +} // namespace HomieInternals diff --git a/src/Homie/Utils/Validation.cpp b/src/Homie/Utils/Validation.cpp index 235e4bd1..84f67696 100644 --- a/src/Homie/Utils/Validation.cpp +++ b/src/Homie/Utils/Validation.cpp @@ -48,6 +48,11 @@ ConfigValidationResult Validation::_validateConfigRoot(const JsonObject& object) return result; } + if (object.containsKey(F("device_stats_interval")) && !object[F("device_stats_interval")].is()) { + result.reason = F("device_stats_interval is not an integer"); + return result; + } + result.valid = true; return result; } @@ -76,6 +81,94 @@ ConfigValidationResult Validation::_validateConfigWifi(const JsonObject& object) result.reason = F("wifi.password is too long"); return result; } + // by benzino + if (object["wifi"].as().containsKey("bssid") && !object["wifi"]["bssid"].is()) { + result.reason = F("wifi.bssid is not a string"); + return result; + } + if ((object["wifi"].as().containsKey("bssid") && !object["wifi"].as().containsKey("channel")) || + (!object["wifi"].as().containsKey("bssid") && object["wifi"].as().containsKey("channel"))) { + result.reason = F("wifi.channel_bssid channel and BSSID is required"); + return result; + } + if (object["wifi"].as().containsKey("bssid") && !Helpers::validateMacAddress(object["wifi"].as().get("bssid"))) { + result.reason = F("wifi.bssid is not valid mac"); + return result; + } + if (object["wifi"].as().containsKey("channel") && !object["wifi"]["channel"].is()) { + result.reason = F("wifi.channel is not an integer"); + return result; + } + if (object["wifi"].as().containsKey("ip") && !object["wifi"]["ip"].is()) { + result.reason = F("wifi.ip is not a string"); + return result; + } + if (object["wifi"]["ip"] && strlen(object["wifi"]["ip"]) + 1 > MAX_IP_STRING_LENGTH) { + result.reason = F("wifi.ip is too long"); + return result; + } + if (object["wifi"]["ip"] && !Helpers::validateIP(object["wifi"].as().get("ip"))) { + result.reason = F("wifi.ip is not valid ip address"); + return result; + } + if (object["wifi"].as().containsKey("mask") && !object["wifi"]["mask"].is()) { + result.reason = F("wifi.mask is not a string"); + return result; + } + if (object["wifi"]["mask"] && strlen(object["wifi"]["mask"]) + 1 > MAX_IP_STRING_LENGTH) { + result.reason = F("wifi.mask is too long"); + return result; + } + if (object["wifi"]["mask"] && !Helpers::validateIP(object["wifi"].as().get("mask"))) { + result.reason = F("wifi.mask is not valid mask"); + return result; + } + if (object["wifi"].as().containsKey("gw") && !object["wifi"]["gw"].is()) { + result.reason = F("wifi.gw is not a string"); + return result; + } + if (object["wifi"]["gw"] && strlen(object["wifi"]["gw"]) + 1 > MAX_IP_STRING_LENGTH) { + result.reason = F("wifi.gw is too long"); + return result; + } + if (object["wifi"]["gw"] && !Helpers::validateIP(object["wifi"].as().get("gw"))) { + result.reason = F("wifi.gw is not valid gateway address"); + return result; + } + if ((object["wifi"].as().containsKey("ip") && (!object["wifi"].as().containsKey("mask") || !object["wifi"].as().containsKey("gw"))) || + (object["wifi"].as().containsKey("gw") && (!object["wifi"].as().containsKey("mask") || !object["wifi"].as().containsKey("ip"))) || + (object["wifi"].as().containsKey("mask") && (!object["wifi"].as().containsKey("ip") || !object["wifi"].as().containsKey("gw")))) { + result.reason = F("wifi.staticip ip, gw and mask is required"); + return result; + } + if (object["wifi"].as().containsKey("dns1") && !object["wifi"]["dns1"].is()) { + result.reason = F("wifi.dns1 is not a string"); + return result; + } + if (object["wifi"]["dns1"] && strlen(object["wifi"]["dns1"]) + 1 > MAX_IP_STRING_LENGTH) { + result.reason = F("wifi.dns1 is too long"); + return result; + } + if (object["wifi"]["dns1"] && !Helpers::validateIP(object["wifi"].as().get("dns1"))) { + result.reason = F("wifi.dns1 is not valid dns address"); + return result; + } + if (object["wifi"].as().containsKey("dns2") && !object["wifi"].as().containsKey("dns1")) { + result.reason = F("wifi.dns2 no dns1 defined"); + return result; + } + if (object["wifi"].as().containsKey("dns2") && !object["wifi"]["dns2"].is()) { + result.reason = F("wifi.dns2 is not a string"); + return result; + } + if (object["wifi"]["dns2"] && strlen(object["wifi"]["dns2"]) + 1 > MAX_IP_STRING_LENGTH) { + result.reason = F("wifi.dns2 is too long"); + return result; + } + if (object["wifi"]["dns2"] && !Helpers::validateIP(object["wifi"].as().get("dns2"))) { + result.reason = F("wifi.dns2 is not valid dns address"); + return result; + } const char* wifiSsid = object["wifi"]["ssid"]; if (strcmp_P(wifiSsid, PSTR("")) == 0) { @@ -183,23 +276,44 @@ ConfigValidationResult Validation::_validateConfigSettings(const JsonObject& obj settingsObject = &(object["settings"].as()); } + if (settingsObject->size() > MAX_CONFIG_SETTING_SIZE) {//max settings here and in isettings + result.reason = F("settings contains more elements than the set limit"); + return result; + } + for (IHomieSetting* iSetting : IHomieSetting::settings) { + enum class Issue { + Type, + Validator, + Missing + }; + auto setReason = [&result, &iSetting](Issue issue) { + switch (issue) { + case Issue::Type: + result.reason = String(iSetting->getName()) + F(" setting is not a ") + String(iSetting->getType()); + break; + case Issue::Validator: + result.reason = String(iSetting->getName()) + F(" setting does not pass the validator function"); + break; + case Issue::Missing: + result.reason = String(iSetting->getName()) + F(" setting is missing"); + break; + } + }; + if (iSetting->isBool()) { HomieSetting* setting = static_cast*>(iSetting); if (settingsObject->containsKey(setting->getName())) { if (!(*settingsObject)[setting->getName()].is()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is not a boolean")); + setReason(Issue::Type); return result; } else if (!setting->validate((*settingsObject)[setting->getName()].as())) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting does not pass the validator function")); + setReason(Issue::Validator); return result; } } else if (setting->isRequired()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is missing")); + setReason(Issue::Missing); return result; } } else if (iSetting->isLong()) { @@ -207,17 +321,14 @@ ConfigValidationResult Validation::_validateConfigSettings(const JsonObject& obj if (settingsObject->containsKey(setting->getName())) { if (!(*settingsObject)[setting->getName()].is()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is not a long")); + setReason(Issue::Type); return result; } else if (!setting->validate((*settingsObject)[setting->getName()].as())) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting does not pass the validator function")); + setReason(Issue::Validator); return result; } } else if (setting->isRequired()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is missing")); + setReason(Issue::Missing); return result; } } else if (iSetting->isDouble()) { @@ -225,17 +336,14 @@ ConfigValidationResult Validation::_validateConfigSettings(const JsonObject& obj if (settingsObject->containsKey(setting->getName())) { if (!(*settingsObject)[setting->getName()].is()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is not a double")); + setReason(Issue::Type); return result; } else if (!setting->validate((*settingsObject)[setting->getName()].as())) { - result.reason = String(setting->getName()); - result.reason.concat((" setting does not pass the validator function")); + setReason(Issue::Validator); return result; } } else if (setting->isRequired()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is missing")); + setReason(Issue::Missing); return result; } } else if (iSetting->isConstChar()) { @@ -243,17 +351,14 @@ ConfigValidationResult Validation::_validateConfigSettings(const JsonObject& obj if (settingsObject->containsKey(setting->getName())) { if (!(*settingsObject)[setting->getName()].is()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is not a const char*")); + setReason(Issue::Type); return result; } else if (!setting->validate((*settingsObject)[setting->getName()].as())) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting does not pass the validator function")); + setReason(Issue::Validator); return result; } } else if (setting->isRequired()) { - result.reason = String(setting->getName()); - result.reason.concat(F(" setting is missing")); + setReason(Issue::Missing); return result; } } @@ -262,3 +367,23 @@ ConfigValidationResult Validation::_validateConfigSettings(const JsonObject& obj result.valid = true; return result; } + +// bool Validation::_validateConfigWifiBssid(const char *mac) { +// int i = 0; +// int s = 0; +// while (*mac) { +// if (isxdigit(*mac)) { +// i++; +// } +// else if (*mac == ':' || *mac == '-') { +// if (i == 0 || i / 2 - 1 != s) +// break; +// ++s; +// } +// else { +// s = -1; +// } +// ++mac; +// } +// return (i == MAX_MAC_STRING_LENGTH && s == 5); +// } diff --git a/src/Homie/Utils/Validation.hpp b/src/Homie/Utils/Validation.hpp index 31a4cbfd..efa39fc7 100644 --- a/src/Homie/Utils/Validation.hpp +++ b/src/Homie/Utils/Validation.hpp @@ -3,6 +3,7 @@ #include "Arduino.h" #include +#include "Helpers.hpp" #include "../Limits.hpp" #include "../../HomieSetting.hpp" diff --git a/src/HomieEvent.hpp b/src/HomieEvent.hpp index 270c4c60..c514db74 100644 --- a/src/HomieEvent.hpp +++ b/src/HomieEvent.hpp @@ -8,12 +8,13 @@ enum class HomieEventType : uint8_t { CONFIGURATION_MODE, NORMAL_MODE, OTA_STARTED, + OTA_PROGRESS, OTA_SUCCESSFUL, OTA_FAILED, ABOUT_TO_RESET, WIFI_CONNECTED, WIFI_DISCONNECTED, - MQTT_CONNECTED, + MQTT_READY, MQTT_DISCONNECTED, MQTT_PACKET_ACKNOWLEDGED, READY_TO_SLEEP @@ -31,4 +32,7 @@ struct HomieEvent { AsyncMqttClientDisconnectReason mqttReason; /* MQTT_PACKET_ACKNOWLEDGED */ uint16_t packetId; + /* OTA_PROGRESS */ + size_t sizeDone; + size_t sizeTotal; }; diff --git a/src/HomieNode.cpp b/src/HomieNode.cpp index 6f05b3cd..909deec5 100644 --- a/src/HomieNode.cpp +++ b/src/HomieNode.cpp @@ -9,7 +9,7 @@ PropertyInterface::PropertyInterface() : _property(nullptr) { } -void PropertyInterface::settable(PropertyInputHandler inputHandler) { +void PropertyInterface::settable(const PropertyInputHandler& inputHandler) { _property->settable(inputHandler); } @@ -18,27 +18,24 @@ PropertyInterface& PropertyInterface::setProperty(Property* property) { return *this; } -HomieNode::HomieNode(const char* id, const char* type, NodeInputHandler inputHandler) +HomieNode::HomieNode(const char* id, const char* type, const NodeInputHandler& inputHandler) : _id(id) , _type(type) , _properties() +, runLoopDisconnected(false) , _inputHandler(inputHandler) { if (strlen(id) + 1 > MAX_NODE_ID_LENGTH || strlen(type) + 1 > MAX_NODE_TYPE_LENGTH) { - Interface::get().getLogger() << F("✖ HomieNode(): either the id or type string is too long") << endl; - Serial.flush(); - abort(); + Helpers::abort(F("✖ HomieNode(): either the id or type string is too long")); + return; // never reached, here for clarity } Homie._checkBeforeSetup(F("HomieNode::HomieNode")); HomieNode::nodes.push_back(this); } -HomieNode::~HomieNode() -{ - Interface::get().getLogger() << F("✖ ~HomieNode(): Destruction of HomieNode object not possible") << endl; - Interface::get().getLogger() << F(" Hint: Don't create HomieNode objects as a local variable (e.g. in setup())") << endl; - Serial.flush(); - abort(); +HomieNode::~HomieNode() { + Helpers::abort(F("✖✖ ~HomieNode(): Destruction of HomieNode object not possible\n Hint: Don't create HomieNode objects as a local variable (e.g. in setup())")); + return; // never reached, here for clarity } PropertyInterface& HomieNode::advertise(const char* property) { diff --git a/src/HomieNode.hpp b/src/HomieNode.hpp index 83503ced..8a508f94 100644 --- a/src/HomieNode.hpp +++ b/src/HomieNode.hpp @@ -24,7 +24,7 @@ class PropertyInterface { public: PropertyInterface(); - void settable(PropertyInputHandler inputHandler = [](const HomieRange& range, const String& value) { return false; }); + void settable(const PropertyInputHandler& inputHandler = [](const HomieRange& range, const String& value) { return false; }); private: PropertyInterface& setProperty(Property* property); @@ -37,7 +37,7 @@ class Property { public: explicit Property(const char* id, bool range = false, uint16_t lower = 0, uint16_t upper = 0) { _id = strdup(id); _range = range; _lower = lower; _upper = upper; _settable = false; } - void settable(PropertyInputHandler inputHandler) { _settable = true; _inputHandler = inputHandler; } + void settable(const PropertyInputHandler& inputHandler) { _settable = true; _inputHandler = inputHandler; } private: const char* getProperty() const { return _id; } @@ -61,7 +61,7 @@ class HomieNode { friend HomieInternals::BootConfig; public: - HomieNode(const char* id, const char* type, HomieInternals::NodeInputHandler nodeInputHandler = [](const String& property, const HomieRange& range, const String& value) { return false; }); + HomieNode(const char* id, const char* type, const HomieInternals::NodeInputHandler& nodeInputHandler = [](const String& property, const HomieRange& range, const String& value) { return false; }); virtual ~HomieNode(); const char* getId() const { return _id; } @@ -72,6 +72,10 @@ class HomieNode { HomieInternals::SendingPromise& setProperty(const String& property) const; + void setRunLoopDisconnected(bool runLoopDisconnected) { + this->runLoopDisconnected = runLoopDisconnected; + } + protected: virtual void setup() {} virtual void loop() {} @@ -89,8 +93,10 @@ class HomieNode { return 0; } + const char* _id; const char* _type; + bool runLoopDisconnected; std::vector _properties; HomieInternals::NodeInputHandler _inputHandler; diff --git a/src/HomieSetting.cpp b/src/HomieSetting.cpp index e9b2bea0..ea03f459 100644 --- a/src/HomieSetting.cpp +++ b/src/HomieSetting.cpp @@ -2,16 +2,34 @@ using namespace HomieInternals; -std::vector IHomieSetting::settings; +std::vector __attribute__((init_priority(101))) IHomieSetting::settings; + +HomieInternals::IHomieSetting::IHomieSetting(const char * name, const char * description) + : _name(name) + , _description(description) + , _required(true) + , _provided(false) { +} + +bool IHomieSetting::isRequired() const { + return _required; +} + +const char* IHomieSetting::getName() const { + return _name; +} + +const char* IHomieSetting::getDescription() const { + return _description; +} + + template HomieSetting::HomieSetting(const char* name, const char* description) -: _name(name) -, _description(description) -, _required(true) -, _provided(false) -, _value() -, _validator([](T candidate) { return true; }) { + : IHomieSetting(name, description) + , _value() + , _validator([](T candidate) { return true; }) { IHomieSetting::settings.push_back(this); } @@ -33,7 +51,7 @@ HomieSetting& HomieSetting::setDefaultValue(T defaultValue) { } template -HomieSetting& HomieSetting::setValidator(std::function validator) { +HomieSetting& HomieSetting::setValidator(const std::function& validator) { _validator = validator; return *this; } @@ -49,21 +67,6 @@ void HomieSetting::set(T value) { _provided = true; } -template -bool HomieSetting::isRequired() const { - return _required; -} - -template -const char* HomieSetting::getName() const { - return _name; -} - -template -const char* HomieSetting::getDescription() const { - return _description; -} - template bool HomieSetting::isBool() const { return false; } @@ -78,17 +81,26 @@ bool HomieSetting::isConstChar() const { return false; } template<> bool HomieSetting::isBool() const { return true; } +template<> +const char* HomieSetting::getType() const { return "bool"; } template<> bool HomieSetting::isLong() const { return true; } +template<> +const char* HomieSetting::getType() const { return "long"; } template<> bool HomieSetting::isDouble() const { return true; } +template<> +const char* HomieSetting::getType() const { return "double"; } template<> bool HomieSetting::isConstChar() const { return true; } +template<> +const char* HomieSetting::getType() const { return "string"; } -template class HomieSetting; // Needed because otherwise undefined reference to +// Needed because otherwise undefined reference to +template class HomieSetting; template class HomieSetting; template class HomieSetting; template class HomieSetting; diff --git a/src/HomieSetting.hpp b/src/HomieSetting.hpp index 6f97ef43..0df2dc33 100644 --- a/src/HomieSetting.hpp +++ b/src/HomieSetting.hpp @@ -4,26 +4,41 @@ #include #include "Arduino.h" +#include "./Homie/Datatypes/Callbacks.hpp" + namespace HomieInternals { +class HomieClass; class Config; class Validation; class BootConfig; class IHomieSetting { public: - IHomieSetting() {} + static std::vector settings; + + bool isRequired() const; + const char* getName() const; + const char* getDescription() const; virtual bool isBool() const { return false; } virtual bool isLong() const { return false; } virtual bool isDouble() const { return false; } virtual bool isConstChar() const { return false; } - static std::vector settings; + virtual const char* getType() const { return "unknown"; } + + protected: + explicit IHomieSetting(const char* name, const char* description); + const char* _name; + const char* _description; + bool _required; + bool _provided; }; } // namespace HomieInternals template class HomieSetting : public HomieInternals::IHomieSetting { + friend HomieInternals::HomieClass; friend HomieInternals::Config; friend HomieInternals::Validation; friend HomieInternals::BootConfig; @@ -33,24 +48,19 @@ class HomieSetting : public HomieInternals::IHomieSetting { T get() const; bool wasProvided() const; HomieSetting& setDefaultValue(T defaultValue); - HomieSetting& setValidator(std::function validator); + HomieSetting& setValidator(const std::function& validator); private: - const char* _name; - const char* _description; - bool _required; - bool _provided; T _value; std::function _validator; bool validate(T candidate) const; void set(T value); - bool isRequired() const; - const char* getName() const; - const char* getDescription() const; bool isBool() const; bool isLong() const; bool isDouble() const; bool isConstChar() const; + + const char* getType() const; }; diff --git a/src/SendingPromise.cpp b/src/SendingPromise.cpp index 3546e713..bfdf8d44 100644 --- a/src/SendingPromise.cpp +++ b/src/SendingPromise.cpp @@ -40,7 +40,7 @@ SendingPromise& SendingPromise::setRange(uint16_t rangeIndex) { } uint16_t SendingPromise::send(const String& value) { - if (!Interface::get().connected) { + if (!Interface::get().ready) { Interface::get().getLogger() << F("✖ setNodeProperty(): impossible now") << endl; return 0; }