From 1b8a5ff806be410b6e9a621170fe403d5f28b115 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 22:13:44 +0000 Subject: [PATCH] readme for 2.0: what it needs, quick starts, the interface table, the MQTT contract, log lines, memory, migration from 1.x Rewritten along section 6a of the design. The Light example is in the readme in full; the 13 other code blocks are excerpts of the examples and of test/readme/Configuration/Configuration.ino, a sketch that exists to compile the calls of the Configuration section (setServer, setTimeSource, setLogLevel, AlexaLog::setOutput). The interface table says which six interfaces Alexa drove from this library on 2026-09-28 and that the rest is not tried. ESP32: never compiled. The changelog is in CHANGELOG.md. Built for d1_mini: 17 examples and the Configuration sketch, 0 warnings; 147 host tests pass. Co-Authored-By: Claude Fable 5.1 --- readme.md | 932 +++++++++++++------- test/readme/Configuration/Configuration.ino | 40 + 2 files changed, 670 insertions(+), 302 deletions(-) create mode 100644 test/readme/Configuration/Configuration.ino diff --git a/readme.md b/readme.md index 8604354..6ed488e 100644 --- a/readme.md +++ b/readme.md @@ -1,36 +1,38 @@ # Alex2ESP -Alex2ESP is a lightweight Arduino/PlatformIO library for integrating ESP8266 microcontrollers with Amazon Alexa smart home APIs through the [Alex2MQTT](https://alex2mqtt.stormysdream.club/) skill. The library simplifies the process of creating Alexa-compatible devices using MQTT. +Alex2ESP is an Arduino/PlatformIO library that makes an ESP8266 a set of Amazon Alexa smart home devices. The sketch declares devices and their capabilities (a lamp with `PowerController`, a blind with a `RangeController`); the library announces them to Alexa, hands every directive to a handler of the sketch and sends the answers and reports. It talks to Alexa through the [Alex2MQTT](https://alex2mqtt.stormysdream.club/) skill, over MQTT only. -**Supported target: ESP8266** (developed and tested on a Wemos D1 mini). ESP32: untested - the code carries include guards for it, but it has never been compiled or run there. +One of the most popular libraries for controlling ESP devices with Alexa is [FauxmoESP](https://github.com/vintlabs/fauxmoESP). It works without an account by emulating a light bulb, so it is limited to the commands of one. Alex2ESP needs the Alex2MQTT account and announces the device as what it is. -For more details on how to configure Alex2ESP, visit [Alex2MQTT Documentation](https://alex2mqtt.stormysdream.club/). +- [What it needs](#what-it-needs) · [Supported boards](#supported-boards) +- [Quick start: PlatformIO](#quick-start-platformio) · [Quick start: Arduino IDE](#quick-start-arduino-ide) · [The Light example](#the-light-example) +- [Writing a sketch](#writing-a-sketch) · [Interface Types](#interface-types) +- [The MQTT contract](#the-mqtt-contract) · [Session and log lines](#session-and-log-lines) +- [Memory](#memory) · [Configuration](#configuration) +- [Migrating from 1.x](#migrating-from-1x) · [Limits](#limits) · [Tests](#tests) · [Changelog](CHANGELOG.md) --- +## What it needs +- **An Alex2MQTT account.** Log in with Amazon at https://alex2mqtt.stormysdream.club/. The page shows the three credentials a sketch passes to `begin()`: the MQTT user name, the MQTT password and the root topic. +- **An ESP8266 board** with the Arduino core 3.x. +- **Two libraries**: AsyncMqttClient 0.9.x (which needs ESPAsyncTCP) and ArduinoJson 7. +- **The network**: outbound TCP port 1883 to the broker, and DNS and outbound UDP port 123 for the time of day (`pool.ntp.org`, `time.nist.gov`). -## Features -- Supports "All" Alexa smart home capabilities. -- Provides action mapping for advanced directive customization including Open,Close,Raise,and Lower commands. -- Allows custom JSON injection in both discovery and state reporting to allow for unsupported devices. -- Event-driven architecture for handling Alexa directives. ---- +## Supported boards -## Alternatives - - one of the most popular library for controlling esp like devices using alexa is [FauxmoESP](https://github.com/vintlabs/fauxmoESP) - however FauxmoESP works by emulating a light bulb so it is limited in what commands it accepts. +| Board | State | +|---|---| +| ESP8266 | Developed and measured on a Wemos D1 mini (PlatformIO, espressif8266 4.2.1, Arduino core 3.1.2). Every figure in this readme is from that board or that build. | +| ESP32 | Untested. The sources have `#ifdef` branches for it, but the library has never been compiled for an ESP32 or run on one. The examples include `ESP8266WiFi.h`, and `library.json` and `library.properties` name the ESP8266 only. | --- -## Quick Start -### Installation +## Quick start: PlatformIO The library lives on Forgejo at https://git.stormysdream.club/platformio/Alex2ESP (public). It is not in the PlatformIO registry or the Arduino Library Manager: install it from git. -#### PlatformIO - ```ini [env:d1_mini] platform = espressif8266 @@ -38,48 +40,605 @@ board = d1_mini framework = arduino monitor_speed = 74880 lib_deps = - https://git.stormysdream.club/platformio/Alex2ESP.git + https://git.stormysdream.club/platformio/Alex2ESP.git#v2.0.0 marvinroger/AsyncMqttClient@^0.9.0 bblanchon/ArduinoJson@^7 ``` -To pin a release instead of `main`, append the tag: `https://git.stormysdream.club/platformio/Alex2ESP.git#v1.1.0`. AsyncMqttClient pulls in ESPAsyncTCP by itself; if PlatformIO notes that more than one `ESPAsyncTCP` package matches, add `esp32async/ESPAsyncTCP@^2.0.0` to `lib_deps` to pick one explicitly. The examples print at 74880 baud, the ESP8266 boot-ROM rate, so the boot messages stay readable in the same monitor. +`#v2.0.0` pins the release; without it PlatformIO takes `main`. AsyncMqttClient pulls in ESPAsyncTCP by itself; if PlatformIO notes that more than one `ESPAsyncTCP` package matches, add `esp32async/ESPAsyncTCP@^2.0.0` to `lib_deps` to pick one explicitly. The examples print at 74880 baud, the ESP8266 boot-ROM rate, so the boot messages stay readable in the same monitor. -#### Arduino IDE +Copy [`examples/Light/Light.ino`](examples/Light/Light.ino) into `src/` of the project, fill in the five constants at its top and upload. -Download the repository as a ZIP (https://git.stormysdream.club/platformio/Alex2ESP/archive/main.zip) and add it with *Sketch -> Include Library -> Add .ZIP Library*. Install **AsyncMqttClient**, **ArduinoJson** (7.x) and **ESP Async TCP** (by ESP32Async) from the Library Manager. Install the ESP8266 board package and select your board (for example *LOLIN(WEMOS) D1 R2 & mini*). +## Quick start: Arduino IDE + +1. Download the repository as a ZIP (https://git.stormysdream.club/platformio/Alex2ESP/archive/v2.0.0.zip) and add it with *Sketch -> Include Library -> Add .ZIP Library*. +2. Install **AsyncMqttClient**, **ArduinoJson** (7.x) and **ESP Async TCP** (by ESP32Async) from the Library Manager. +3. Install the ESP8266 board package and select your board (for example *LOLIN(WEMOS) D1 R2 & mini*). +4. Open *File -> Examples -> Alex2ESP -> Light*, fill in the five constants at its top and upload. + +The examples are built with PlatformIO. They are laid out as the Arduino IDE expects them, one folder per sketch, but they have not been built with the IDE yet. + +## The Light example + +[`examples/Light/Light.ino`](examples/Light/Light.ino), complete: -Then include the library in your project: ```cpp +// Light: a lamp that Alexa switches on and off (Alexa.PowerController). +// +// The lamp is the on-board LED. One handler gets every directive of the device: it answers ReportState with the +// state of the lamp, carries out TurnOn and TurnOff, and refuses anything else. +#include +#include #include + +// Wi-Fi and the credentials of your Alex2MQTT account +const char *WIFI_SSID = ""; +const char *WIFI_PASSWORD = ""; + +const char *ALEXA_USERNAME = ""; +const char *ALEXA_PASSWORD = ""; +const char *ALEXA_ROOT_TOPIC = ""; + +// The on-board LED: GPIO2 on a Wemos D1 mini, lit when the pin is low +#ifndef LED_BUILTIN +#define LED_BUILTIN 2 +#endif + +Alex2ESP alexa; +bool lampOn = false; + +// Joins the Wi-Fi network and returns after 30 s at the latest. Without a connection the sketch carries on: the +// ESP8266 keeps trying, and alexa.loop() opens the MQTT session once Wi-Fi is up. +void connectWiFi() +{ + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + Serial.printf("\n[WIFI] Connecting to \"%s\"\n", WIFI_SSID); + + unsigned long started = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - started < 30000) + { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) + { + Serial.printf("[WIFI] Connected, IP address %s\n", WiFi.localIP().toString().c_str()); + } + else + { + Serial.printf("[WIFI] No connection after 30 s (status %d): check WIFI_SSID and WIFI_PASSWORD. Still trying.\n", + WiFi.status()); + } +} + +// The state of the lamp: what the answer to a directive and the answer to ReportState carry +void sendState(AlexaStatusMessage message) +{ + message.addHealthProp(EndpointHealth::OK) + .addPowerControllerProp(lampOn ? PowerController::ON : PowerController::OFF) + .send(); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + if (directive.type == AlexaInterfaceType::POWER_CONTROLLER && (directive.is("TurnOn") || directive.is("TurnOff"))) + { + lampOn = directive.is("TurnOn"); + digitalWrite(LED_BUILTIN, lampOn ? LOW : HIGH); + sendState(directive.response()); + return; + } + + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lamp switches on and off").send(); +} + +void setup() +{ + Serial.begin(74880); + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, HIGH); + + connectWiFi(); + + // MQTT user name, MQTT password, root topic + alexa.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); + + // The name Alexa shows, and the id of the endpoint: every device of an account has its own + AlexaDevice *lamp = alexa.getDevice("Desk Lamp", "esp-light"); + lamp->setDisplayCategory(DisplayCategory::LIGHT); + lamp->addCapability(AlexaInterfaces::PowerController); + lamp->addCapability(AlexaInterfaces::EndpointHealth); + lamp->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} ``` -Complete sketches are in [`examples/`](examples/), one folder per kind of device: `Light`, `DimmableLight`, `ColorTemperatureLight`, `ColorLight`, `TemperatureSensor`, `ContactSensor`, `Blind`, `Thermostat`, `Lock`, `Scene`, `Doorbell` and `MultiDevice`; [`examples/README.md`](examples/README.md) says what each does and what it takes of RAM and flash. The examples of 1.x are in [`examples/legacy/`](examples/legacy/). Every example joins Wi-Fi with `WiFi.begin(WIFI_SSID, WIFI_PASSWORD)`; fill in the SSID, the password and your Alex2MQTT credentials before flashing. +After the upload, let Alexa discover devices. The serial monitor shows the session, the discovery and one line per directive. + +The other examples, one folder per kind of device: `DimmableLight`, `ColorTemperatureLight`, `ColorLight`, `TemperatureSensor`, `ContactSensor`, `Blind`, `Thermostat`, `Lock`, `Scene`, `Doorbell` and `MultiDevice`. [`examples/README.md`](examples/README.md) says what each does. The examples of 1.x are in [`examples/legacy/`](examples/legacy/). --- -## Creating a Basic Device -### Example: Light Control +## Writing a sketch -Here’s how to create a simple device that controls a light: +A sketch has one `Alex2ESP`, calls `begin(username, password, rootTopic)` once and `loop()` from its own `loop()`. `getDevice(name, endpointId)` creates a device, `addCapability()` gives it an interface, `onDirective()` its handler. The code blocks of this section are excerpts of the examples; the file is named under each. + +### Devices and capabilities + +`getDevice()` is called after `begin()`. The endpoint id is what Alexa knows the device by: two boards with the same endpoint id on one account are one device to Alexa. The pointers returned by `getDevice()` and `addCapability()` stay valid for the lifetime of the client. `getDevice()` returns `nullptr`, with an error in the log, when the heap has no room for another device. The examples use the pointer unchecked, because they create their devices in `setup()`; a sketch that creates devices by the dozen, or later than `setup()`, checks it. + +A device holds 8 capabilities. Every device of the examples has `EndpointHealth` and reports `connectivity` with its state. + +### The handler + +`onDirective()` registers a function that gets every directive of its device, `ReportState` included. `AlexaDirective` has: + +| Member | What it is | +|---|---| +| `device` | the device the directive is for | +| `capability`, `type` | the capability of the device with the namespace and the instance of the directive; `nullptr` and `AlexaInterfaceType::UNKNOWN` when the device has none, and for `ReportState` | +| `ns`, `name`, `instance` | `"Alexa.PowerController"`, `"TurnOn"`, `"Blind.Lift"` (`""` for an interface without instances) | +| `correlationToken` | what the answer has to repeat; the library does that | +| `payload` | the payload of the directive, `directive.payload["rangeValue"]` | +| `is(name)`, `isReportState()` | compare the name of the directive | +| `response()`, `stateReport()`, `error(type, message)`, `deferred(seconds)`, `sceneStarted()`, `sceneStopped()` | build the answer; `send()` sends it | + +The texts are those of the directive and valid until the handler returns. The backend waits 7 s for the answer to a directive, so the handler answers before it returns. + +One function can serve several devices; the directive says which one it is for: -First create an instance of the alexa client and initilize it with your MQTT Credentials ```cpp -Alex2ESP alexClient; +void onDirective(AlexaDirective &directive) +{ + int lamp = directive.device == lamps[1] ? 1 : 0; + + if (directive.isReportState()) + { + sendState(directive.stateReport(), lamp); + return; + } ``` -Credentials can be found at https://alex2mqtt.stormysdream.club/ after logging in with amazon. `begin()` takes the MQTT username, the MQTT password and the root topic, in that order (the same order as alex2node's `new Alex2MQTT(username, password, rootTopic)`). +[`examples/MultiDevice/MultiDevice.ino`](examples/MultiDevice/MultiDevice.ino) + +What the library does when the handler sends nothing: + +| Case | Answer | Log | +|---|---|---| +| the device has no handler | ErrorResponse `INVALID_DIRECTIVE` | `... refused as INVALID_DIRECTIVE: the device has no handler, give it one with onDirective()` | +| the directive names a capability the device does not have | ErrorResponse `INVALID_DIRECTIVE` | `... refused as INVALID_DIRECTIVE: the device has no such capability, and its handler sent no answer` | +| the directive is for a capability of the device | none; Alexa reports that the device does not respond | `... the handler sent no answer to ...` | + +### Capabilities with an instance + +`RangeController`, `ModeController` and `ToggleController` are generic controllers: a device may have several of one interface, each with an instance and at least one name. `addCapability()` takes the row and the instance; the setters of the capability return the capability, so they can be chained. What the discovery object needs beside the names (the range of a value, the modes) the sketch writes in the function it passes to `setConfiguration()`. + ```cpp - alexClient.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); +// The "configuration" of the capability in discovery: the range of the value and its unit +void liftConfiguration(JsonObject configuration, void *) +{ + JsonObject range = configuration["supportedRange"].to(); + range["minimumValue"] = 0; + range["maximumValue"] = 100; + range["precision"] = 1; + configuration["unitOfMeasure"] = FPSTR(AlexaUnits::Percent); +} ``` -Once initilized you can begin to add virtual devices, in this example we add a power controller to toggle a light ```cpp + blind->addCapability(AlexaInterfaces::RangeController, LIFT) + ->addFriendlyAsset(AlexaAssets::Setting_Opening) + .setConfiguration(liftConfiguration) + .addActionMapping(ActionMapping({AlexaAction::Close}, "SetRangeValue", "{\"rangeValue\":0}")) + .addActionMapping(ActionMapping({AlexaAction::Open}, "SetRangeValue", "{\"rangeValue\":100}")) + .addActionMapping(ActionMapping({AlexaAction::Lower}, "AdjustRangeValue", + "{\"rangeValueDelta\":-10,\"rangeValueDeltaDefault\":false}")) + .addActionMapping(ActionMapping({AlexaAction::Raise}, "AdjustRangeValue", + "{\"rangeValueDelta\":10,\"rangeValueDeltaDefault\":false}")) + .addStateMapping({AlexaState::Closed}, 0) + .addStateMapping({AlexaState::Open}, 1, 100); +``` - AlexaDevice* device1 = alexClient.getDevice("Test Lamp", "ESP-01"); - device1->setDisplayCategory(DisplayCategory::LIGHT); - device1->addCapability(AlexaInterfaceType::POWER_CONTROLLER); +[`examples/Blind/Blind.ino`](examples/Blind/Blind.ino) +- An `ActionMapping` maps words of Alexa ("open", "close", "raise", "lower") to a directive: the actions, the name of the directive and its payload as JSON text. The [Alexa documentation](https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-discovery-objects.html#action-mapping) lists the actions. Action and state mappings are taken by the three generic controllers only. +- `AlexaAssets` and `AlexaUnits` (`src/AlexaResources.h`) hold the names and units of the catalog of Alexa, which Alexa translates: `AlexaAssets::Setting_Opening` is `Alexa.Setting.Opening`. `addFriendlyName("Lift", "en-US")` adds a name as text. An id that the sketch does not name takes no flash. +- A capability holds 3 names, 4 action mappings and 2 state mappings. The instance and the text of a friendly name are copied. The locale, an asset id, the text value of a state mapping and the directive name and payload of an `ActionMapping` are kept as pointers: pass literals. +- A setter that refuses what it is given prints the reason and the chain goes on. `isValid()` of the capability is `false` from then on, and the device leaves the capability out of discovery with an error, so that Alexa does not learn half of it. +- `addCapability()` returns `nullptr` for a generic controller without an instance, for an interface the library has no row for, and for a ninth capability: check the pointer when the instance is not a literal. +- `setProactivelyReported(true)` announces that the sketch sends ChangeReports for the capability, `setNonControllable(true)` that it reports its state and takes no directives. + +### Reports + +`response()` answers a directive that changed something, `stateReport()` answers `ReportState`. Both take the properties of the device, one helper per property (see [Interface Types](#interface-types)), and are sent with `send()`. Every property carries the board's UTC time as `timeOfSample`; the last argument of a helper is the time since the value was read, in milliseconds: + +```cpp + directive.stateReport() + .addHealthProp(EndpointHealth::OK) + .addTemperatureSensorProp(temperature, TemperatureSensorScale::CELSIUS, millis() - lastRead) + .send(); +``` + +[`examples/TemperatureSensor/TemperatureSensor.ino`](examples/TemperatureSensor/TemperatureSensor.ino) + +A value outside of the range Alexa gives for its property (brightness, percentage, power level and humidity 0 to 100, color temperature 1000 to 10000, hue 0 to 360, saturation and brightness of a color 0 to 1) is reported as the nearest value of the range, with an error in the log. + +`send()` returns `false` when the message was not sent: no session with the broker, the MQTT client or the heap cannot take it, or it is over `ALEX2ESP_MAX_MESSAGE`. Each case prints its reason. Nothing is sent truncated. + +### Change reports + +A `ChangeReport` tells Alexa that a property changed without a directive: a door opened, somebody pressed the switch on the lamp. The capability is announced with `setProactivelyReported(true)`. The properties added before `context()` are those that changed, the ones after it the others. A `ChangeReport` without a property that changed is not sent and prints an error. + +```cpp + contact->changeReport(AlexaCause::PHYSICAL_INTERACTION) + .addContactSensorProp(isOpen) + .context() + .addHealthProp(EndpointHealth::OK) + .send(); +``` + +[`examples/ContactSensor/ContactSensor.ino`](examples/ContactSensor/ContactSensor.ino) + +A sketch that reports a value that drifts limits how often it does so, and keeps what it reported only when the report left: + +```cpp + bool sent = sensor->changeReport(AlexaCause::PERIODIC_POLL) + .addTemperatureSensorProp(temperature) + .context() + .addHealthProp(EndpointHealth::OK) + .send(); + if (sent) + { + reported = temperature; + everReported = true; + lastReport = millis(); + } +``` + +[`examples/TemperatureSensor/TemperatureSensor.ino`](examples/TemperatureSensor/TemperatureSensor.ino) + +### Deferred answers + +A device that takes longer than the 7 s answers with a `DeferredResponse`, keeps a copy of the correlation token and sends the answer itself when it is done, with `sendAsync()`. Alexa accepts a deferred answer for `LockController` and `WakeOnLANController` only. + +```cpp + goal = directive.is("Lock") ? AlexaLockState::LOCKED : AlexaLockState::UNLOCKED; + token = directive.correlationToken; // a copy: the text of the directive ends with the handler + moving = true; + movingSince = millis(); + digitalWrite(BOLT_PIN, goal == AlexaLockState::LOCKED ? HIGH : LOW); + + directive.deferred(5).send(); +``` + +```cpp + if (moving && millis() - movingSince >= BOLT_MS) + { + moving = false; + state = digitalRead(BLOCKED_PIN) == LOW ? AlexaLockState::JAMMED : goal; + doorLock->response(token).addHealthProp(EndpointHealth::OK).addLockControllerProp(state).sendAsync(); + } +``` + +[`examples/Lock/Lock.ino`](examples/Lock/Lock.ino): the handler, and `loop()` + +### Errors + +`error(type, message)` answers a directive that was not carried out. `AlexaErrorType` has the types of Alexa; the message is for the log of the skill. What a type carries beside it goes into `payload()`: + +```cpp + int position = directive.payload["rangeValue"] | -1; + if (position < 0 || position > 100) + { + AlexaStatusMessage error = directive.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "The blind takes 0 to 100"); + error.payload()["validRange"]["minimumValue"] = 0; + error.payload()["validRange"]["maximumValue"] = 100; + error.send(); + return; + } +``` + +[`examples/Blind/Blind.ino`](examples/Blind/Blind.ino) + +The types of a thermostat (`REQUESTED_SETPOINTS_TOO_CLOSE`, `THERMOSTAT_IS_OFF`, ...) are sent in the namespace `Alexa.ThermostatController`; [`examples/Thermostat/Thermostat.ino`](examples/Thermostat/Thermostat.ino) sends `THERMOSTAT_IS_OFF`, `DUAL_SETPOINTS_UNSUPPORTED`, `REQUESTED_SETPOINTS_TOO_CLOSE` and `UNSUPPORTED_THERMOSTAT_MODE`. + +### Scenes + +A scene has no state to report. `Activate` is answered with `sceneStarted()` in place of a Response, `Deactivate` with `sceneStopped()`: + +```cpp + if (directive.is("Activate")) + { + setScene(true); + directive.sceneStarted().send(); + } + else if (directive.is("Deactivate")) + { + setScene(false); + directive.sceneStopped().send(); + } +``` + +[`examples/Scene/Scene.ino`](examples/Scene/Scene.ino) + +### Doorbells + +A doorbell sends an event and takes no directives: + +```cpp + if (pressed) + { + Serial.println("[DOORBELL] pressed"); + doorbell->doorbellPress().send(); + } +``` + +[`examples/Doorbell/Doorbell.ino`](examples/Doorbell/Doorbell.ino) + +`device->event(row, name)` builds the event of another interface. + +--- + +## Interface Types + +The library describes 28 interfaces, each with a row in program memory (`src/AlexaInterfaces.cpp`). `addCapability()` takes the row, `AlexaInterfaces::PowerController`, or the type as 1.x sketches write it, `AlexaInterfaceType::POWER_CONTROLLER`. A sketch links the rows it names and no others, and the report helpers it calls and no others. + +The last column says whether Alexa discovered and drove the interface **from this library**, on a Wemos D1 mini with a real Alexa account through the public Alex2MQTT service, on 2026-09-28. "no" means not tried, not that it failed. + +| Row in `AlexaInterfaces` | Version | Properties | Report helper | Example | Driven through Alexa | +|---|---|---|---|---|---| +| `EndpointHealth` | 3.1 | connectivity | `addHealthProp` | all | not checked on its own | +| `PowerController` | 3 | powerState | `addPowerControllerProp` | Light | yes | +| `BrightnessController` | 3 | brightness | `addBrightnessControllerProp` | DimmableLight | yes | +| `ColorTemperatureController` | 3 | colorTemperatureInKelvin | `addColorTemperatureControllerProp` | ColorTemperatureLight | yes | +| `ColorController` | 3 | color | `addColorControllerProp(hue, saturation, brightness)` | ColorLight | no | +| `PowerLevelController` | 3 | powerLevel | `addPowerLevelControllerProp` | | no | +| `PercentageController` | 3 | percentage | `addPercentageControllerProp` | | no | +| `RangeController` | 3 | rangeValue | `addRangeControllerProp(instance, value)` | Blind | yes, one instance | +| `ModeController` | 3 | mode | `addModeControllerProp(instance, mode)` | | yes, one instance | +| `ToggleController` | 3 | toggleState | `addToggleControllerProp(instance, state)` | | yes, one instance | +| `ThermostatController` | 3.2 | targetSetpoint, lowerSetpoint, upperSetpoint, thermostatMode | `addThermostatSetpointProp`, `addThermostatLowerSetpointProp`, `addThermostatUpperSetpointProp` (both: `addThermostatDualSetpointProp(lower, upper)`), `addThermostatModeProp` | Thermostat | no | +| `TemperatureSensor` | 3 | temperature | `addTemperatureSensorProp(value, scale)` | TemperatureSensor | no | +| `HumiditySensor` | 3 | relativeHumidity | `addHumiditySensorProp` | | no | +| `LockController` | 3 | lockState | `addLockControllerProp` | Lock | no | +| `ContactSensor` | 3 | detectionState | `addContactSensorProp` | ContactSensor | no | +| `MotionSensor` | 3 | detectionState | `addMotionSensorProp` | | no | +| `TimeHoldController` | 3 | holdStartTime, holdEndTime | `addProperty` | | no | +| `Speaker` | 3 | volume, muted | `addProperty` | | no | +| `PlaybackStateReporter` | 3 | playbackState | `addProperty` | | no | +| `InputController` | 3 | input | `addProperty` | | no | +| `ChannelController` | 3 | channel | `addProperty` | | no | +| `InventoryLevelSensor` | 3 | level | `addProperty` | | no | +| `PlaybackController` | 3 | `"properties": {}` | | | no | +| `WakeOnLANController` | 3 | `"properties": {}` | | | no | +| `SceneController` | 3 | no `properties` object | | Scene | no | +| `DoorbellEventSource` | 3 | no `properties` object | | Doorbell | no | +| `StepSpeaker` | 3 | no `properties` object | | | no | +| `SimpleEventSource` | 1.0 | no `properties` object | | | no | + +The six interfaces with "yes" were driven with another sketch than the examples, which have not run on a board yet. The directives were answered within 0.2 to 0.4 s, and a directive that was delivered twice was answered once. Voice commands have not been tried. + +The Alex2MQTT service itself has carried more than that. With its Node.js client, alex2node 1.5.2, on the same day: Power, Brightness, Color, ColorTemperature, Percentage, PowerLevel, Thermostat, Range, Mode, Toggle, Lock and Scene, deferred responses, ErrorResponse, ReportState, and ChangeReports for contact, motion, lock, temperature and power. So the way from the broker to Alexa is tried for these; what this library sends for them is checked by the host tests only. + +`addProperty(row, place, value)` adds a property that has no helper by its place in the row, 0 for the first: `addProperty(AlexaInterfaces::Speaker, 1, muted)` reports `muted`. `addContextProp(object)` adds a property that the sketch has written as JSON. + +Every other value of `AlexaInterfaceType` (`COOKING`, `LAUNCHER`, `SECURITY_PANEL_CONTROLLER`, ...) names an interface the library has no row for. `addCapability()` adds nothing for it, prints `[Alex2ESP] error: : capability not added: ...` and returns `nullptr`. + +--- + +## The MQTT contract + +Everything goes over MQTT, to port 1883 of `alex2mqtt.stormysdream.club` or of the broker named with `setServer()`. The library makes no HTTP requests. It subscribes with QoS 1 and publishes with QoS 0. + +| Topic | Direction | What | +|---|---|---| +| `/discover` | in | the request to announce the devices | +| `/discover_r` | out | one discovery object per device, one message each | +| `//alexaDirective` | in | the directive as JSON; subscribed as `/+/alexaDirective` | +| `//alexaResponce` | out | Response, StateReport, ErrorResponse, DeferredResponse, ActivationStarted, DeactivationStarted | +| `//deferredResponse` | out | the answer after a DeferredResponse (`sendAsync()`) | +| `/changeReport` | out | ChangeReport | +| `/event` | out | DoorbellPress and the events of `event(row, name)` | + +**Discovery.** The backend accepts one endpoint object per message and collects everything that arrives within 1 s for Alexa's discovery answer (up to 5 s for its proactive AddOrUpdate push), so all devices are published back to back from the next `loop()`. Each object sits on the heap (about 1 KB) until the MQTT client has sent it. When the client cannot take another one (free heap under 4 KB), the library prints `discovery deferred at ` and sends the rest from `loop()` as the queue drains, for up to 5 s after the request. + +**Directives.** A directive larger than one TCP segment arrives in fragments, which are put together in one heap block that exists until `loop()` has parsed it. Every call of `loop()` handles one directive, in the order of arrival. + +**Sizes.** + +| Limit | Default | Build flag | +|---|---|---| +| largest directive | 2047 bytes | `ALEX2ESP_MAX_DIRECTIVE` | +| largest message sent: a report, or the discovery object of one device | 3071 bytes, the largest directive plus 1024 | `ALEX2ESP_MAX_MESSAGE` | +| directives that wait for `loop()` | 8 | `ALEX2ESP_MAX_QUEUED_DIRECTIVES` | +| bytes they take together | 8188, four times the largest directive | `ALEX2ESP_MAX_QUEUED_BYTES` | + +Alex2MQTT publishes directives of 600 to 900 bytes; most of that is the correlation token. An answer repeats the token and adds 140 to 170 bytes per property, so the largest directive can be answered with six properties. A directive over the limit, and one that finds no place among the waiting ones, is dropped with an error in the log. + +**The queue.** Alexa sends a group command ("turn off the kitchen") as one directive per endpoint, and they arrive faster than a busy sketch calls `loop()`. Up to eight wait for it. + +**Duplicates.** A directive that arrives twice is handled once. The Alex2MQTT broker currently delivers every message twice through a mirror. The repeat is recognised while it arrives, by a hash of its bytes, and takes no place in the queue; a directive that comes again with other bytes is recognised by its `messageId`. The last 16 directives are remembered. + +**Other boards.** The subscription delivers the directives of every endpoint of the account. Those for endpoints of another board are recognised by their topic and neither buffered nor parsed. + +**Messages.** Every message has a `messageId` of its own, a UUID of version 4 from the random source of the hardware. + +**Boards with 1.x.** Boards that run 1.1.0 or older keep working: the backend still publishes the token on `//alexaDirective_e` and serves the HTTP routes they use. + +--- + +## Session and log lines + +`begin()` starts SNTP and returns. `loop()` opens the MQTT session once Wi-Fi is up and the clock is set, or after 5 s of Wi-Fi without an answer from a time server. `getState()` is `Alex2ESPState::CONNECTED` when the broker has acknowledged both subscriptions. + +A session that ends or cannot be opened is opened again, whatever ended it, for as long as Wi-Fi is up: + +| | | +|---|---| +| wait before the next attempt | 1 s, then 2 s, 4 s, ... up to 60 s | +| the wait starts at 1 s again | after a session that lasted a minute | +| an attempt without an answer | given up after 30 s | +| MQTT keep-alive | 30 s | +| while Wi-Fi is down | nothing is tried; the session is opened when Wi-Fi is back | + +Tried on a D1 mini against a scripted broker (2026-09-28): the waits of 1 to 60 s, a refused password, a broker that does not answer, and loss of Wi-Fi. + +Every line of the library starts with `[Alex2ESP]`, a problem with `[Alex2ESP] error:`. The user name, the password, the root topic and correlation tokens are never printed, at any level, so a log can be posted as it is: a topic appears without its root (`esp-light/alexaResponce`), a directive under its endpoint id. + +| Line | What it means | What to do | +|---|---|---| +| `connected to , subscribing` | the broker accepted the session | | +| `ready: N device(s)` | both subscriptions are acknowledged | | +| `discovery: N of M device(s) announced` | the answer to a discovery request | | +| `esp-light <- Alexa.PowerController.TurnOn` | a directive was handed to the handler | | +| `waiting for Wi-Fi: the sketch has to join a network with WiFi.begin()` | `loop()` runs and Wi-Fi is not up; printed once | check the SSID and the password of the sketch | +| `error: Wi-Fi is down, waiting for it` | the link was lost; printed once per loss | nothing: the session is opened again when Wi-Fi is back | +| `error: disconnected: the broker refused the username or the password, ...; next attempt in N s` | the first two arguments of `begin()` are not those of the account | copy them again from the Alex2MQTT page. This is the reason to look for when a board never shows up in Alexa | +| `error: disconnected: the broker cannot be reached or the connection was lost; ...` | no TCP connection, or it ended | check the network and port 1883; the library tries again | +| `error: disconnected: the broker did not answer within 30 s; ...` | the attempt was given up | as above | +| `error: the broker refused the subscription to /discover: check the root topic passed to begin()` | the root topic is not the one of the account; the state stays `SUBSCRIBING` | correct the third argument of `begin()` | +| `error: the clock is not set, reports carry a time in 1970: ...` | no answer from a time server; printed when the session opens and at most once a minute while reports are sent | allow DNS and outbound UDP port 123, or set the clock in the sketch and call `setTimeSource(false)` | +| `discovery deferred at ` | the heap is short, the rest is sent from `loop()` | nothing | +| `error: discovery gave up: N device(s) not announced` | those devices missed this answer; on the backend's proactive discovery that can remove them from Alexa until the next one | fewer devices on the board, or more free heap | +| `error: device not announced: its discovery object has N bytes, ...` | the object is over `ALEX2ESP_MAX_MESSAGE` | fewer capabilities or mappings on the device, or a higher limit | +| `error: : directive of N bytes dropped: ...` | over `ALEX2ESP_MAX_DIRECTIVE`, no place in the queue, or no memory | call `loop()` more often, or raise the limit | +| `: repeated directive ignored` | the directive arrived a second time | nothing | +| `error: : the handler sent no answer to ...` | the handler returned without `send()` | answer with `response()` or `error()` | +| `error: : N bytes not sent, ...` | `send()` returned `false`: no session, the MQTT client or the heap is full, or the message is over `ALEX2ESP_MAX_MESSAGE` | | + +--- + +## Memory + +Static RAM and flash as PlatformIO reports them for `d1_mini` (espressif8266 4.2.1, Arduino core 3.1.2, AsyncMqttClient 0.9.0, ArduinoJson 7.4.3), built from the examples as committed, with empty credentials and the default log level. A D1 mini has 81,920 bytes of RAM; what is not static is the heap. + +| Sketch | Static RAM (bytes) | Flash (bytes) | +|---|---:|---:| +| Light | 30,616 | 332,565 | +| DimmableLight | 30,692 | 336,765 | +| ColorTemperatureLight | 30,808 | 337,489 | +| ColorLight | 30,748 | 338,657 | +| TemperatureSensor | 30,572 | 333,213 | +| ContactSensor | 30,584 | 332,861 | +| Blind | 30,860 | 336,525 | +| Thermostat | 31,044 | 337,517 | +| Lock | 30,648 | 333,441 | +| Scene | 30,640 | 333,277 | +| Doorbell | 30,584 | 333,013 | +| MultiDevice | 30,672 | 332,725 | +| legacy/basicLight | 30,520 | 333,989 | +| legacy/lightWithBrightness | 30,648 | 337,929 | +| legacy/lightWithColorTemp | 30,828 | 338,653 | +| legacy/tempSensor | 30,412 | 332,781 | +| legacy/blindControl | 30,568 | 335,261 | + +For comparison: `basicLight` with 1.1.0 took 52,768 bytes of static RAM and 350,885 of flash. A sketch with Wi-Fi and Serial and nothing else takes 28,152 and 269,771 with the same toolchain, one that also uses the two dependencies 29,000 and 299,401. + +Devices and capabilities are on the heap, a capability with 116 bytes. `MultiDevice`, with two devices, has 56 bytes of static RAM more than `Light`: the arrays of the sketch. + +### What a capability costs + +Measured on the prototype of the 2.0 design, not on the release: a lamp with `PowerController` and `EndpointHealth` (29,412 bytes of static RAM, 329,293 of flash) plus one capability as a sketch uses it, with its `addCapability()`, the handling of its directives and its report helper in the Response and the StateReport. The figures of the release differ by some bytes; the table shows what is cheap and what is not. + +| + capability | Static RAM | Flash | +|---|---:|---:| +| TemperatureSensor, report only | +0 | +356 | +| ContactSensor with its ChangeReport | +0 | +368 | +| ToggleController, instance and name | +32 | +620 | +| LockController with the deferred answer | +24 | +984 | +| SceneController | +48 | +1,000 | +| ColorTemperatureController | +88 | +1,280 | +| ColorController | +48 | +1,316 | +| BrightnessController, PowerLevelController, PercentageController, each | +56 | +1,376 | +| ModeController, two modes with their names | +116 | +2,640 | +| RangeController, range, one action and one state mapping | +136 | +2,872 | +| ThermostatController with TemperatureSensor | +204 | +3,112 | + +An interface that the sketch does not name costs nothing: the ESP8266 core links with `--gc-sections`, in PlatformIO and in the Arduino IDE, and every row is an object of its own. In the examples the step from `Light` to `DimmableLight` is larger than the table says (+4,200 bytes of flash) because the sketch also begins to use `analogWrite()`. + +### Build flags that change the figures + +| Flag | Effect, measured on `legacy/basicLight` (30,520 / 333,989) | +|---|---| +| `-DALEX2ESP_LOG_MAX=0` | no log lines compiled in: 30,496 / 327,953 | +| `-DALEX2ESP_LOG_MAX=3` | the `DEBUG` lines compiled in: 30,520 / 334,477 | +| `-DALEX2ESP_MAX_CAPABILITIES=` | 4 bytes of heap per place in every device | +| `-DALEX2ESP_MAX_FRIENDLY_NAMES=`, `-DALEX2ESP_MAX_ACTION_MAPPINGS=`, `-DALEX2ESP_MAX_STATE_MAPPINGS=` | 8 or 12 bytes of heap per place in every capability | + +--- + +## Configuration + +```cpp + // Another broker than alex2mqtt.stormysdream.club:1883. The text is not copied: a literal, or a buffer that stays + alexa.setServer("broker.example.org", 1883); + + // The sketch sets the clock itself; the library then leaves SNTP alone + alexa.setTimeSource(false); + + // NONE, ERROR, INFO (the default) or DEBUG; DEBUG needs -DALEX2ESP_LOG_MAX=3 + alexa.setLogLevel(AlexaLogLevel::DEBUG); + + // Where the lines go: any Print, Serial unless changed + AlexaLog::setOutput(&Serial); + + alexa.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); +``` + +[`test/readme/Configuration/Configuration.ino`](test/readme/Configuration/Configuration.ino) + +| Call | When | What | +|---|---|---| +| `setServer(host, port)` | before `begin()` | another broker, for example your own instance of Alex2MQTT. After `begin()` it is ignored with an error | +| `setTimeSource(false)` | before `begin()` | for a sketch with its own `configTime()` or an RTC | +| `setLogLevel(level)` | any time | `ERROR` leaves only the problems, `NONE` nothing; `INFO` adds the session, discovery and one line per directive; `DEBUG` adds sizes and free heap per message | +| `AlexaLog::setOutput(print)` | any time | where the lines go; `Serial` unless changed, `nullptr` for nowhere | + +The user name and the password passed to `begin()` are not copied either. + +The limits are build flags, set for the whole project in `platformio.ini`: + +```ini +build_flags = + -DALEX2ESP_LOG_MAX=3 + -DALEX2ESP_MAX_CAPABILITIES=12 + -DALEX2ESP_MAX_DIRECTIVE=3071 +``` + +| Flag | Default | Range | +|---|---|---| +| `ALEX2ESP_LOG_MAX` | 2 (`INFO`) | 0 to 3: the highest level that is compiled in | +| `ALEX2ESP_MAX_DIRECTIVE` | 2047 | | +| `ALEX2ESP_MAX_MESSAGE` | `ALEX2ESP_MAX_DIRECTIVE` + 1024 | under `ALEX2ESP_MAX_DIRECTIVE` + 300 the answer of a lamp to the largest directive is refused | +| `ALEX2ESP_MAX_QUEUED_DIRECTIVES` | 8 | 1 to 64 | +| `ALEX2ESP_MAX_QUEUED_BYTES` | 4 x `ALEX2ESP_MAX_DIRECTIVE` | `ALEX2ESP_MAX_DIRECTIVE` or more | +| `ALEX2ESP_MAX_CAPABILITIES` | 8 | 1 to 100 | +| `ALEX2ESP_MAX_FRIENDLY_NAMES` | 3 | 1 to 255 | +| `ALEX2ESP_MAX_ACTION_MAPPINGS` | 4 | 1 to 255 | +| `ALEX2ESP_MAX_STATE_MAPPINGS` | 2 | 1 to 255 | + +The Arduino IDE has no flags per sketch and builds with the defaults. `setLogLevel()` with a level that is not compiled in prints an error that names the flag. + +A sketch that defines a macro named `DEBUG`, `ERROR` or `INFO` cannot write the level of that name; it passes the number instead, `alexa.setLogLevel(static_cast(3))` for `DEBUG`. + +--- + +## Migrating from 1.x + +The five examples of 1.x compile against 2.0 as they are, without a warning; they are in [`examples/legacy/`](examples/legacy/). `registerEvent()`, `buildStatusMessage()`, the helpers with a capital letter (`AddHealthProp`, `AddPowerControllerProp`, ...), `addCapability(AlexaInterfaceType::...)`, `AlexaInterface` and `AlexaActions` stay: + +```cpp device1->registerEvent("ReportState", [](const JsonDocument& directive, const AlexaInterfaceType& type) { // Build and send status report device1->buildStatusMessage(directive["header"]["correlationToken"]) @@ -87,291 +646,60 @@ Once initilized you can begin to add virtual devices, in this example we add a p .AddPowerControllerProp(outputState) .send(); }); - - // Register event listener for "Event" directive to handle state changes - device1->registerEvent("Event", [](const JsonDocument& directive, const AlexaInterfaceType& type) { - if (type == AlexaInterfaceType::POWER_CONTROLLER) { - if (directive["header"]["name"] == "TurnOn") { - outputState = PowerController::ON; - Serial.println("Turning ON"); - } else if (directive["header"]["name"] == "TurnOff") { - outputState = PowerController::OFF; - Serial.println("Turning OFF"); - } - - // Send the updated state after change - device1->buildStatusMessage(directive["header"]["correlationToken"], true) - .AddHealthProp(EndpointHealth::OK) - .AddPowerControllerProp(outputState) - .send(); - } - }); ``` -The pointers returned by `getDevice()` and `addCapability()` stay valid for the lifetime of the client, so keeping them in globals and adding more devices or capabilities later is fine. `getDevice()` returns `nullptr`, with an error on Serial, when the heap has no room for another device. The examples use the pointer unchecked, because they create one device in `setup()`; a sketch that creates devices by the dozen, or later than `setup()`, checks it. +[`examples/legacy/basicLight/basicLight.ino`](examples/legacy/basicLight/basicLight.ino) ---- -### Example: Toggle Controller for Blinds -Alex2ESP also supports action mapping so we can map keywords such as "open" and "close" to a toggle controller -For a comprehensive list of Alexa actions and mappings, visit the [Alexa Developer Documentation](https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-discovery-objects.html#action-mapping) +A device with a handler of `onDirective()` does not call the handlers of `registerEvent()`. -```cpp -AlexaDevice* device = alexClient.getDevice("Bedroom Blinds", "ESP-01"); -device->setDisplayCategory(DisplayCategory::INTERIOR_BLIND); +What changes for a 1.x sketch that is compiled against 2.0: -AlexaInterface* toggleController = device->addCapability(AlexaInterfaceType::TOGGLE_CONTROLLER); -toggleController->setInstance("ESP-01.Toggle"); -toggleController->addFriendlyName("Bedroom Blinds", "en-US"); +| 1.x | 2.0 | What to do | +|---|---|---| +| directives fetched and reports posted over HTTP | both over MQTT; the HTTP fallback is removed | nothing. The library no longer includes `ESP8266HTTPClient`: a sketch that uses it includes it itself | +| `timeOfSample` filled in by the backend | the board's own time | the board has to reach a time server (DNS, UDP port 123), or the sketch sets the clock and calls `setTimeSource(false)` | +| the session opens in `begin()` | `loop()` opens it when the clock is set, at most 5 s later | nothing | +| reconnect every 5 s after a lost TCP connection only, without a line | after every end of a session, with the reason and a growing wait | nothing | +| `EndpointHealth` announced as 3.3, `ThermostatController` as 3 without properties, `Speaker`, `StepSpeaker`, `PlaybackStateReporter`, `InventoryLevelSensor` and `WakeOnLANController` as 1 | 3.1, 3.2 with four properties, and 3, as the interface pages of Alexa have them; the interfaces that were announced with an empty list of properties have their properties | let Alexa discover the devices again | +| `AlexaInterfaceType::AUTHORIZATION_CONTROLLER`, `AUTOMOTIVE_VEHICLE_DATA` | removed; Alexa has withdrawn both | remove the capability | +| `addCapability()` with a type the library has no description of announced it as version 1 without properties | adds nothing, prints an error and returns `nullptr` | check the pointer; announce the interface when the library has a row for it | +| `AddTemperatureSensorProp(TemperatureSensorScale::FAHRENHEIT, 69)` reported 20.56 `CELSIUS` | reports 69 `FAHRENHEIT` | nothing | +| a value outside of its range was sent as it was | the nearest value of the range, with an error | nothing | +| the handler of `Event` got the type of the namespace | the type of the capability of its device, `UNKNOWN` when the device has none | add the capability | +| a second `registerEvent()` for a name was ignored | replaces the handler | | +| the locale and the directive name and payload of an `ActionMapping` were copied | kept as pointers | pass literals | +| any number of capabilities, names and mappings | 8 capabilities per device, 3 names, 4 action and 2 state mappings per capability | build flags, see [Configuration](#configuration) | +| a directive nobody answered ran into the timeout | the library answers `INVALID_DIRECTIVE` when the device has no handler for it | | +| an `AlexaDevice` or `AlexaStatusMessage` the sketch constructed itself could send | `send()` returns `false`: it has no client | use `getDevice()` and `buildStatusMessage()` | +| `#define Alex2ESP_DEBUG` in `AlexaUtils.cpp` | `setLogLevel()` and `-DALEX2ESP_LOG_MAX` | | +| `AlexaInterfaceUtils`, the queue functions of `AlexaUtils`, `AlexaInterface::getJSON()` and `getProps()`, `ActionMapping::getJSON()`, `FriendlyName`, `MAX_STATUS_REPORT_SIZE` | removed | `alexaInterfaceRow(type)` gives the row of a type, with its namespace, version and properties; the limit is `ALEX2ESP_MAX_MESSAGE` | -ActionMapping closeMapping({AlexaActions::Close}, "TurnOn"); -ActionMapping openMapping({AlexaActions::Open}, "TurnOff"); -toggleController->addActionMapping(closeMapping); -toggleController->addActionMapping(openMapping); -``` - -An `ActionMapping` takes an optional third argument, the directive payload as JSON text (for example `"{\"rangeValue\": 0}"` for a RangeController). It is emitted as an object in the discovery response and omitted when empty. - -### Example: a blind with a position - -`RangeController`, `ModeController` and `ToggleController` are generic controllers: a device may have several of one interface, each with an instance and at least one name. `addCapability()` takes the row and the instance and returns an `AlexaCapability`; its setters return the capability, so they can be chained. - -```cpp -void fillLift(JsonObject configuration, void*) { - JsonObject range = configuration["supportedRange"].to(); - range["minimumValue"] = 0; - range["maximumValue"] = 100; - range["precision"] = 1; - configuration["unitOfMeasure"] = FPSTR(AlexaUnits::Percent); -} - -device->addCapability(AlexaInterfaces::RangeController, "Blind.Lift") - ->addFriendlyAsset(AlexaAssets::Setting_Opening) - .addFriendlyName("Lift", "en-US") - .setConfiguration(fillLift) - .addActionMapping(ActionMapping({AlexaAction::Close, AlexaAction::Lower}, "SetRangeValue", "{\"rangeValue\":0}")) - .addActionMapping(ActionMapping({AlexaAction::Open, AlexaAction::Raise}, "SetRangeValue", "{\"rangeValue\":100}")) - .addStateMapping({AlexaState::Closed}, 0) - .addStateMapping({AlexaState::Open}, 1, 100); -``` - -`AlexaAssets` and `AlexaUnits` (`src/AlexaResources.h`) hold the names and units of the catalog of Alexa, which Alexa translates: `AlexaAssets::Setting_Opening` is `Alexa.Setting.Opening`, `AlexaUnits::Temperature_Celsius` is `Alexa.Unit.Temperature.Celsius`. An id that the sketch does not name takes no flash. - -A setter that refuses what it is given prints the reason and the chain goes on. `isValid()` of the capability is `false` from then on, and the device leaves the capability out of discovery with an error, so that Alexa does not learn half of it. - -`addCapability()` returns `nullptr` for a generic controller without an instance: check the pointer when the instance is not a literal. A capability holds 3 names, 4 action mappings and 2 state mappings (`-DALEX2ESP_MAX_FRIENDLY_NAMES`, `-DALEX2ESP_MAX_ACTION_MAPPINGS`, `-DALEX2ESP_MAX_STATE_MAPPINGS` in `build_flags` change that). The instance and the text of a friendly name are copied. The locale, an asset id, the text value of a state mapping and the directive name and payload of an `ActionMapping` are kept as pointers: pass literals. `setNonControllable(true)` announces a capability that reports its state and takes no directives. - -A device holds 8 capabilities (`-DALEX2ESP_MAX_CAPABILITIES`); `addCapability()` for one more prints an error and returns `nullptr`. - -### Example: one handler for several devices -`onDirective()` registers a function that gets every directive of its device, `ReportState` included, together with the device and the capability the directive is for. The same function can serve any number of devices: - -```cpp -AlexaDevice* lamps[2]; -bool on[2]; - -void onLamp(AlexaDirective& d) { - bool& state = on[d.device == lamps[0] ? 0 : 1]; - if (d.isReportState()) { - d.stateReport().AddHealthProp(EndpointHealth::OK).AddPowerControllerProp(state ? PowerController::ON : PowerController::OFF).send(); - return; - } - if (d.type != AlexaInterfaceType::POWER_CONTROLLER) { - return; // not for a capability of the lamp: the library answers INVALID_DIRECTIVE - } - state = d.is("TurnOn"); - d.response().AddHealthProp(EndpointHealth::OK).AddPowerControllerProp(state ? PowerController::ON : PowerController::OFF).send(); -} - -// in setup(), after begin() -lamps[0] = alexClient.getDevice("Desk Lamp", "ESP-01"); -lamps[1] = alexClient.getDevice("Floor Lamp", "ESP-02"); -for (AlexaDevice* lamp : lamps) { - lamp->setDisplayCategory(DisplayCategory::LIGHT); - lamp->addCapability(AlexaInterfaces::PowerController); - lamp->onDirective(onLamp); -} -``` - -`d.ns`, `d.name`, `d.instance`, `d.correlationToken` and `d.payload` are those of the directive and valid until the handler returns. `d.capability` is the capability of the device with the namespace and the instance of the directive, `nullptr` when the device has none and for `ReportState`. A device with such a handler does not call the `ReportState` and `Event` handlers of `registerEvent()`, which keep working for a device without one. +[`CHANGELOG.md`](CHANGELOG.md) has every change. A sketch written for 1.0.0 also meets the change of 1.1.0: `begin()` takes the user name, the password and the root topic, in that order. --- -## How it talks to Alex2MQTT +## Limits -Everything goes over MQTT (port 1883 of `alex2mqtt.stormysdream.club`); the library makes no HTTP requests. `alexClient.setServer("broker.example.org", 1883)` before `begin()` names another broker, for example your own instance of Alex2MQTT. The host is a name or an address as text and is not copied, like the username and the password: pass a literal or a buffer that stays. - -- **Session.** `begin()` starts SNTP (`pool.ntp.org`, `time.nist.gov`) and returns; `loop()` opens the MQTT session once Wi-Fi is up and the clock is set, or after 5 s of Wi-Fi without an answer, and subscribes to `/discover` and `/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to /discover` means that the root topic is not the one of the account. A session that ends or cannot be opened prints `[Alex2ESP] error: disconnected: ; next attempt in N s` and is opened again after 1 s, then 2 s, 4 s and so on up to once a minute, for as long as Wi-Fi is up. The wait starts at 1 s again after a session that lasted a minute: a session that the broker closes right after it has accepted it, as it does to one of two boards with the same client id, is opened again after the longer wait. `the broker refused the username or the password` is the reason to look for when a board never shows up in Alexa, `the broker did not answer within 30 s` stands for an attempt that the library gave up. When the link goes down the library prints `[Alex2ESP] error: Wi-Fi is down, waiting for it`, once per loss; the MQTT client reports the lost connection when its keep-alive runs out, and the session is opened again when Wi-Fi is back. A sketch that sets the clock itself (its own `configTime()` with a time zone, an RTC) calls `alexClient.setTimeSource(false)` before `begin()`. The board has to reach an NTP server: DNS for the two names and outbound UDP port 123. Without the time of day the session still opens, the reports carry a `timeOfSample` in 1970, and the library prints `[Alex2ESP] error: the clock is not set, ...` when it connects and then at most once a minute while reports are sent. -- **Discovery.** On `/discover` the library answers with one discovery object per device on `/discover_r`. The backend accepts one endpoint object per message and collects everything that arrives within 1 s for Alexa's discovery answer (up to 5 s for its proactive AddOrUpdate push), so all devices are published back to back from the next `loop()`. Each object sits on the heap (about 1 KB) until the MQTT client has sent it; when the client cannot take another one (free heap under 4 KB), the library prints `[Alex2ESP] discovery deferred at ` and sends the rest from `loop()` as the queue drains, for up to 5 s after the request. `[Alex2ESP] error: discovery gave up: N device(s) not announced` means those devices missed this answer - on the backend's proactive discovery that can remove them from Alexa until the next one. -- **Directives.** The directive arrives as JSON on `//alexaDirective`. A directive larger than one TCP segment arrives in fragments, which are put together in one heap block that exists only until `loop()` has parsed it. `loop()` then calls the handler of the device: the one of `onDirective()`, or else `ReportState` or `Event` of `registerEvent()` (and before it `DirectiveReceived`, if registered) with the directive: `directive["header"]`, `directive["endpoint"]`, `directive["payload"]`. A device without a handler answers with the ErrorResponse `INVALID_DIRECTIVE`, and so does a device whose handler sent nothing for a directive that names a capability the device does not have; both print an error. A handler that sends nothing for a capability of its device leaves the directive unanswered, which prints `[Alex2ESP] error: : the handler sent no answer to ...`. Every call of `loop()` handles one directive, in the order of arrival. Up to eight directives wait for it: Alexa sends a group command ("turn off the kitchen") as one directive per endpoint, and they arrive faster than a busy sketch calls `loop()`. A directive that arrives twice (a broker that mirrors its topics delivers every message twice) is handled once; the repeat is recognised while it arrives and takes no place among the waiting ones. -- **Reports.** `send()` publishes a message at once, on the topic of its kind. Every property carries the board's UTC time as `timeOfSample`, every message a `messageId` of its own, a UUID made of the random source of the hardware. - - | Kind | Built with | Topic | - |---|---|---| - | Response | `d.response()` | `//alexaResponce` | - | StateReport | `d.stateReport()` | `//alexaResponce` | - | ErrorResponse | `d.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "0 to 100")` | `//alexaResponce` | - | DeferredResponse | `d.deferred(7)` | `//alexaResponce` | - | the answer after it | `device->response(token)` ... `sendAsync()` | `//deferredResponse` | - | ActivationStarted, DeactivationStarted | `d.sceneStarted()`, `d.sceneStopped()` | `//alexaResponce` | - | ChangeReport | `device->changeReport(AlexaCause::PHYSICAL_INTERACTION)` | `/changeReport` | - | DoorbellPress | `device->doorbellPress()` | `/event` | - - The backend waits 7 s for the answer to a directive, so answer from the handler. A device that takes longer (Alexa accepts this for locks and Wake-on-LAN) sends `d.deferred(seconds)`, keeps a copy of `d.correlationToken` and sends the answer when it is done: `device->response(token).addHealthProp(EndpointHealth::OK).sendAsync()`. An `ErrorResponse` takes what its type asks for in `payload()`, for example `payload()["validRange"]["maximumValue"] = 100`; the types of a thermostat (`REQUESTED_SETPOINTS_TOO_CLOSE`, ...) are sent in the namespace `Alexa.ThermostatController`. A `ChangeReport` tells Alexa that properties of a capability with `setProactivelyReported(true)` changed: the properties added before `context()` are those that changed, the ones after it the others. - - ```cpp - device->changeReport(AlexaCause::PHYSICAL_INTERACTION) - .addPowerControllerProp(PowerController::ON) - .context() - .addHealthProp(EndpointHealth::OK) - .send(); - ``` - - A `ChangeReport` without a property that changed is not sent and prints an error. -- **Limits.** A directive may be 2047 bytes (`ALEX2ESP_MAX_DIRECTIVE`), a report or the discovery object of one device 3071 (`ALEX2ESP_MAX_MESSAGE`). The second limit is the first plus 1024 unless it is set: an answer repeats the `correlationToken` of its directive, which is most of a large directive, and adds 140 to 170 bytes per property, so the largest directive can be answered with six properties. Eight directives (`ALEX2ESP_MAX_QUEUED_DIRECTIVES`) of 8188 bytes together (`ALEX2ESP_MAX_QUEUED_BYTES`, four times the largest directive) may wait for `loop()`; one that finds no place is dropped with `[Alex2ESP] error: : directive of N bytes dropped: ...`. `-D=` in `build_flags` changes a limit. `send()` returns `false` when the report was not sent: no session with the broker, the MQTT client or the heap cannot take it, or it is too large. Nothing is ever sent truncated, and nothing is dropped without a line on Serial. -- **Serial output.** Every line of the library starts with `[Alex2ESP]`, a problem with `[Alex2ESP] error:`. `alexClient.setLogLevel(AlexaLogLevel::ERROR)` leaves only the problems, `AlexaLogLevel::NONE` nothing; the default, `AlexaLogLevel::INFO`, adds the session, discovery and one line per directive (`[Alex2ESP] ESP-01 <- Alexa.PowerController.TurnOn`). `AlexaLogLevel::DEBUG` (sizes and free heap per message) has to be compiled in with `-DALEX2ESP_LOG_MAX=3`; `-DALEX2ESP_LOG_MAX=0` compiles every line out. `setLogLevel()` with a level that is not compiled in prints an error that names the flag. `AlexaUtils::printMemoryInfo()` is a utility of the sketch and prints at every level. The username, the password, the root topic and correlation tokens are never printed, at any level, so a log can be posted as it is: a topic appears without its root (`ESP-01/alexaResponce`), a directive under its endpoint id. A sketch that defines a macro named `DEBUG`, `ERROR` or `INFO` cannot write the level of that name; it passes the number instead, for example `alexClient.setLogLevel(static_cast(3))` for `DEBUG`. - -Boards that run 1.1.0 or older keep working: the backend still publishes the token on `//alexaDirective_e` and serves the HTTP routes they use. +- ESP8266 only. The library has never been compiled for an ESP32. +- The session is MQTT over TCP on port 1883, without TLS. +- Reports are published with QoS 0 and sent once. An event or a change while there is no session with the broker is not sent later; `send()` returns `false` and the sketch decides. +- Tried with Alexa from this library: the six interfaces marked in [Interface Types](#interface-types). Not tried from this library: `ColorController`, `ThermostatController`, `LockController` and the deferred answer, `SceneController`, the ErrorResponse, the ChangeReports, `DoorbellPress` and every interface without an example. Voice commands have not been tried. +- The examples are compiled, with 0 warnings, and what each announces in discovery is checked by the host tests. They have not run on a board and have not been built with the Arduino IDE. +- The discovery object of a scene does not carry `supportsDeactivation`, so Alexa may never send `Deactivate`. +- The library keeps no state of the devices. What a device is set to is a variable of the sketch and starts from its initial value after a reset. +- 28 of the interfaces of Alexa have a row. The others cannot be announced. +- The Arduino IDE builds with the default limits. --- -## Interface Types +## Tests -The library describes these interfaces. `addCapability()` takes the row, `AlexaInterfaces::PowerController`, or the type as 1.x sketches write it, `AlexaInterfaceType::POWER_CONTROLLER`. A sketch links the rows it names and no others. The report helpers of 1.x, `AddHealthProp` and the others with a capital letter, stay as names of the ones in the table. - -| Row in `AlexaInterfaces` | `AlexaInterfaceType::` | Version | Properties | Report helper | -|---|---|---|---|---| -| `EndpointHealth` | `ENDPOINT_HEALTH` | 3.1 | connectivity | `addHealthProp` | -| `PowerController` | `POWER_CONTROLLER` | 3 | powerState | `addPowerControllerProp` | -| `BrightnessController` | `BRIGHTNESS_CONTROLLER` | 3 | brightness | `addBrightnessControllerProp` | -| `ColorTemperatureController` | `COLOR_TEMPERATURE_CONTROLLER` | 3 | colorTemperatureInKelvin | `addColorTemperatureControllerProp` | -| `ColorController` | `COLOR_CONTROLLER` | 3 | color | `addColorControllerProp(hue, saturation, brightness)` | -| `PowerLevelController` | `POWER_LEVEL_CONTROLLER` | 3 | powerLevel | `addPowerLevelControllerProp` | -| `PercentageController` | `PERCENTAGE_CONTROLLER` | 3 | percentage | `addPercentageControllerProp` | -| `RangeController` | `RANGE_CONTROLLER` | 3 | rangeValue | `addRangeControllerProp(instance, value)` | -| `ModeController` | `MODE_CONTROLLER` | 3 | mode | `addModeControllerProp(instance, mode)` | -| `ToggleController` | `TOGGLE_CONTROLLER` | 3 | toggleState | `addToggleControllerProp(instance, state)` | -| `ThermostatController` | `THERMOSTAT_CONTROLLER` | 3.2 | targetSetpoint, lowerSetpoint, upperSetpoint, thermostatMode | `addThermostatSetpointProp`, `addThermostatLowerSetpointProp`, `addThermostatUpperSetpointProp` (both: `addThermostatDualSetpointProp(lower, upper)`), `addThermostatModeProp` | -| `TemperatureSensor` | `TEMPERATURE_SENSOR` | 3 | temperature | `addTemperatureSensorProp(value, scale)` | -| `HumiditySensor` | `HUMIDITY_SENSOR` | 3 | relativeHumidity | `addHumiditySensorProp` | -| `LockController` | `LOCK_CONTROLLER` | 3 | lockState | `addLockControllerProp` | -| `ContactSensor` | `CONTACT_SENSOR` | 3 | detectionState | `addContactSensorProp` | -| `MotionSensor` | `MOTION_SENSOR` | 3 | detectionState | `addMotionSensorProp` | -| `TimeHoldController` | `TIME_HOLD_CONTROLLER` | 3 | holdStartTime, holdEndTime | `addProperty` | -| `Speaker` | `SPEAKER` | 3 | volume, muted | `addProperty` | -| `PlaybackStateReporter` | `PLAYBACK_STATE_REPORTER` | 3 | playbackState | `addProperty` | -| `InputController` | `INPUT_CONTROLLER` | 3 | input | `addProperty` | -| `ChannelController` | `CHANNEL_CONTROLLER` | 3 | channel | `addProperty` | -| `InventoryLevelSensor` | `INVENTORY_LEVEL_SENSOR` | 3 | level | `addProperty` | -| `PlaybackController` | `PLAYBACK_CONTROLLER` | 3 | `"properties": {}` | | -| `WakeOnLANController` | `WAKE_ON_LAN_CONTROLLER` | 3 | `"properties": {}` | | -| `SceneController` | `SCENE_CONTROLLER` | 3 | no `properties` object | | -| `DoorbellEventSource` | `DOORBELL_EVENT_SOURCE` | 3 | no `properties` object | | -| `StepSpeaker` | `STEP_SPEAKER` | 3 | no `properties` object | | -| `SimpleEventSource` | `SIMPLE_EVENT_SOURCE` | 1.0 | no `properties` object | | - -Every interface is announced with its version and properties; what else its discovery object needs (the configuration of a thermostat, the presets of a range) the sketch writes in the function it passes to `setConfiguration()`. A report helper is a function of its own, and a sketch links the ones it calls. A value outside of the range Alexa gives for its property (brightness, percentage, power level and humidity 0 to 100, color temperature 1000 to 10000, hue 0 to 360, saturation and brightness of a color 0 to 1) is reported as the nearest value of the range, with an error in the log. `addProperty(row, place, value)` adds a property that has no helper by its place in the row, 0 for the first: `addProperty(AlexaInterfaces::Speaker, 1, muted)` reports `muted`. - -Every other value of `AlexaInterfaceType` (`COOKING`, `LAUNCHER`, `SECURITY_PANEL_CONTROLLER`, ...) names an interface the library has no row for. `addCapability()` adds nothing for it, prints `[Alex2ESP] error: : capability not added: ...` and returns `nullptr`. - ---- - - -## Advanced usage -For devices with limited or partial support, Alex2ESP provides functions that allow you to attach custom JSON objects to the status report. For example, to report the status of a PowerController type, you can use the `AddPowerControllerProp` function. However, if a specific "add props" function does not exist for your use case, you can utilize the `AddContextProp` function to pass a custom JSON object. - -For instance, to manually report the state of a PowerController, you can use the following code: - -```cpp - JsonDocument doc; - - doc["namespace"] = "Alexa.PowerController"; - doc["name"] = "powerState"; - doc["value"] = "ON"; - doc["uncertaintyInMilliseconds"] = 0; - AddContextProp(doc.as()) -``` - -`AddContextProp` sets `timeOfSample` to the current time when the object has none. (Until 1.1.0 a sketch wrote `"{REPLACE_WITH_DATETIME}"` there for the server to replace; the library now replaces that itself.) - ---- - -## Changelog - -### 1.2.0 - -Behaviour changes: -- Directives arrive over MQTT. The library subscribes to `/+/alexaDirective`, where Alex2MQTT has always published the whole directive, instead of fetching it over HTTP with the token from `//alexaDirective_e`. The HTTP detour dates from 2024, when a directive larger than one TCP segment reached the MQTT callback in pieces; the pieces are now put together by their offset and the total length, in one heap block that lives until `loop()` has parsed the directive. `loop()` no longer stalls for two HTTP round trips per directive. -- Reports leave over MQTT. `send()` publishes on `//alexaResponce` at once, where 1.1.0 queued the report for an HTTP POST from a later `loop()`. It returns `false` when there is no session with the broker, when the MQTT client or the heap cannot take the report, or when the report is over 3071 bytes (`ALEX2ESP_MAX_MESSAGE`, by default 1024 more than the largest directive, so that every directive that is accepted can be answered); each case prints its reason. The 5-slot send queue is gone. -- `timeOfSample` is the board's own time in UTC, for example `2026-09-28T13:05:09Z`. 1.1.0 sent the placeholder `{REPLACE_WITH_DATETIME}`, which only the backend's HTTP route replaced. `begin()` starts SNTP (`pool.ntp.org`, `time.nist.gov`) and no longer connects itself: `loop()` opens the MQTT session once the clock is set, or after 5 s without an answer, so the session comes up a few seconds later than before. `alexClient.setTimeSource(false)` before `begin()` leaves the clock to the sketch. `AddContextProp()` fills `timeOfSample` in when the property has none or carries the old placeholder. The board needs an answer from an NTP server (DNS, outbound UDP port 123), which 1.1.0 did not: without one the reports carry a time in 1970, and the library says so when it connects and then at most once a minute while reports are sent. -- A directive is parsed and handed to the sketch from `loop()`, one per call, in the order of arrival. Up to eight directives of 8188 bytes together wait for it on the heap (`ALEX2ESP_MAX_QUEUED_DIRECTIVES`, `ALEX2ESP_MAX_QUEUED_BYTES`; 1.1.0 queued five tokens), because Alexa sends a group command as one directive per endpoint. A directive that finds no place is dropped, as is one over 2047 bytes (`ALEX2ESP_MAX_DIRECTIVE`); both print an error. -- A directive that arrives twice is handled once: the Alex2MQTT broker currently delivers every message twice through a mirror. The repeat is recognised while it arrives, by a hash of its bytes, and takes no place among the waiting directives; a directive that comes again with other bytes is recognised by its `messageId`. The last 16 directives are remembered; the repeat prints `: repeated directive ignored`. -- The subscription delivers the directives of every endpoint of the account. Those for endpoints of another board are recognised by their topic and neither buffered nor parsed. -- Discovery is answered from `loop()`, not inside the MQTT callback. A discovery object over 3071 bytes (`ALEX2ESP_MAX_MESSAGE`) is refused with an error that names the device, `device not announced: ...`; the other devices are still announced. -- `getState()` stays `INITIALIZED` until the first connect, and becomes `CONNECTED` when the broker has acknowledged both subscriptions (1.1.0: the first of them). A subscription that the broker refuses prints an error that names the topic (without its root) and what to check, the root topic passed to `begin()`; the state stays `SUBSCRIBING`. When the MQTT client does not take a subscription, the session is closed and the next one subscribes again. -- Serial output goes through one log with levels. `alexClient.setLogLevel()` takes `AlexaLogLevel::NONE`, `ERROR`, `INFO` (the default) or `DEBUG`; `-DALEX2ESP_LOG_MAX=<0..3>` in `build_flags` sets the highest level that is compiled in (default 2, `INFO`). A level above the highest that is compiled in prints `log level 3 asked for, the lines of this build end at level 2: build with -DALEX2ESP_LOG_MAX=3`. This replaces the `Alex2ESP_DEBUG` define inside `AlexaUtils.cpp`. 1.1.0 printed the memory figures for every MQTT message and the whole directive, correlation token included, for every directive; both are gone. A sketch or a build flag that defines `DEBUG`, `ERROR` or `INFO` as a macro (`#define DEBUG 1`, `-DDEBUG`) still compiles: the library sets those macros aside where it declares its levels and passes a level by its number everywhere else. -- The session is opened again whatever ended it, and the reason is printed in words: `disconnected: the broker refused the username or the password, check the two passed to begin(); next attempt in 1 s`. 1.1.0 reconnected every 5 s after a lost TCP connection only and printed nothing; a board with a wrong password, or one that met the broker while it was restarting, stayed offline until it was reset. The wait is 1 s at first and doubles with every attempt, up to 60 s; it is 1 s again after a session that lasted a minute, so a session that the broker accepts and closes at once does not bring the board back every second. Nothing is tried while Wi-Fi is down, and the first connect waits for Wi-Fi as well (`waiting for Wi-Fi: ...` is printed once). Loss of Wi-Fi prints `Wi-Fi is down, waiting for it`, once per loss. A connect that has no answer after 30 s counts as failed and prints `disconnected: the broker did not answer within 30 s`. The MQTT keep-alive is 30 s (1.1.0: the 15 s of the MQTT client). -- `getDevice()` before `begin()` prints an error: the device would have no root topic. A second `begin()` is ignored with an error. -- A report is sent through the client that created its device. An `AlexaDevice` or an `AlexaStatusMessage` that a sketch constructs itself has no client: `send()` returns `false` and prints `report for not sent: its device was not created by getDevice()`, where 1.1.0 queued the report. Use `getDevice()` and `buildStatusMessage()`. Both constructors take the client as an optional last argument, an `AlexaTransport*`. -- New: `Alex2ESP::setServer(host, port)` before `begin()`, for a broker other than the default; the host is not copied. `Alex2ESP::setLogLevel()`, `Alex2ESP::setTimeSource()`, `AlexaDevice::hasEndpointId()`, `AlexaLog`, `AlexaSendResult`, and `AlexaTransport`, the interface a device sends and stamps its reports through. `Alex2ESP` implements it with `publish(topic, document)`, which sends a `JsonDocument` on the session of the library under the same checks as a report and returns an `AlexaSendResult`, and `timestamp(buffer, size)`, which writes the current time as `timeOfSample` has it. -- Removed: the queues and buffers of `AlexaUtils` (`enqueue`, `dequeue`, `dequeueVals`, `enqueueReceive`, `dequeueReceive`, `isQueueEmpty`, `isQueueFull`, `isReceiveQueueEmpty`, `isReceiveQueueFull`, `receivePayload`, `nextMessageId`) and its `log`/`logln`, which printed nothing unless the library was edited; `AlexaUtils::printMemoryInfo()` stays. `MAX_STATUS_REPORT_SIZE` (the limit is `ALEX2ESP_MAX_MESSAGE`). The library no longer includes `ESP8266HTTPClient`. -- Interfaces are described by rows in program memory, `AlexaInterfaces::PowerController` and 27 more (see Interface Types), in place of the `switch` tables of `AlexaInterfaceUtils`, whose strings took RAM in every sketch. `addCapability()` takes a row or, as before, an `AlexaInterfaceType`; a sketch links only the rows it names. The discovery objects of the five examples are the same byte for byte. -- Versions and properties follow the interface pages of Alexa: `EndpointHealth` is announced as 3.1 (1.1.0: 3.3) and `ThermostatController` as 3.2 with its four properties (1.1.0: 3, none). `Speaker`, `StepSpeaker`, `PlaybackStateReporter`, `InventoryLevelSensor` and `WakeOnLANController` are announced as 3 (1.1.0: 1), `SimpleEventSource` as 1.0. The interfaces that 1.1.0 announced with an empty list of properties have their properties (`lockState`, `detectionState`, `mode`, `rangeValue`, `percentage`, ...). `SceneController`, `DoorbellEventSource`, `StepSpeaker` and `SimpleEventSource` are announced without a `properties` object. -- New: `AlexaInterfaceType::HUMIDITY_SENSOR` with its row, `AlexaInterfaces::HumiditySensor`. `AlexaDevice::getInterfaceType(namespace)`. -- `addCapability()` with a type that has no row adds nothing, prints an error and returns `nullptr`. 1.1.0 announced such an interface as version 1 without properties. -- The handler of `Event` gets the type of the capability of its device that the directive names, and `AlexaInterfaceType::UNKNOWN` for a namespace the device has no capability for. 1.1.0 looked the namespace up among all interfaces. -- Removed: `AlexaInterfaceType::AUTHORIZATION_CONTROLLER` and `AUTOMOTIVE_VEHICLE_DATA`, which Alexa has withdrawn. `AlexaInterfaceUtils` (`toString`, `fromString`, `getVersion`, `getProps`): a row has the namespace, the version and the properties, `alexaInterfaceRow(type)` gives the row of a type. The constructor of `AlexaInterface` takes a row. `DisplayCategory` and `AlexaInterfaceType` are one byte wide. -- New: `AlexaCapability`, which `addCapability()` returns. `addCapability(AlexaInterfaces::RangeController, "Blind.Lift")` takes the instance of an interface that a device may have several of, and a device can have several capabilities of one such interface. `addFriendlyAsset()`, `setConfiguration()`, `addStateMapping()` and `setNonControllable()` are new; every setter returns the capability. `matches(namespace, instance)` tells whether a directive is for the capability. `AlexaInterface` and `AlexaActions` stay as names of `AlexaCapability` and `AlexaAction`, so a 1.x sketch compiles as it is. -- New: `AlexaDevice::onDirective(handler)`. The handler, `void handler(AlexaDirective& d)`, gets every directive of its device with the device (`d.device`), the capability it is for (`d.capability`, `d.type`), its `ns`, `name`, `instance`, `correlationToken` and `payload`, and builds the answer with `d.response()`, `d.stateReport()`, `d.error(type, message)`, `d.deferred(seconds)` or, for a scene, `d.sceneStarted()` and `d.sceneStopped()`; one function can serve several devices. `registerEvent()` stays for the handlers of 1.x, which a device calls when it has no handler of `onDirective()`. `AlexaDevice::findCapability(namespace, instance)`. -- New kinds of messages beside `Response` and `StateReport`: `ErrorResponse` (`AlexaErrorType` has the types of Alexa, `asErrorResponse(type, message)` turns an answer into one), `DeferredResponse` and the answer that follows it (`sendAsync()`, on `//deferredResponse`), `ChangeReport` (`AlexaDevice::changeReport(cause)`, on `/changeReport`), the events of a scene, and `DoorbellPress` (`AlexaDevice::doorbellPress()`, or `event(row, name)` for another interface, on `/event`). `AlexaDevice::response(token)` and `stateReport(token)` build an answer outside of the handler. `payload()` gives the payload of a message, `getKind()` its kind. -- The report helpers begin with a small letter: `addHealthProp`, `addPowerControllerProp`, `addBrightnessControllerProp`, `addColorTemperatureControllerProp`, `addToggleControllerProp(instance, state)`, `addTemperatureSensorProp(value, scale)`, `addContextProp`. The names of 1.x stay, with their order of arguments. -- New report helpers, one for every property of the interfaces a device on this board is likely to have: `addColorControllerProp`, `addPowerLevelControllerProp`, `addPercentageControllerProp`, `addRangeControllerProp`, `addModeControllerProp`, `addLockControllerProp` (`AlexaLockState`), `addContactSensorProp`, `addMotionSensorProp`, `addHumiditySensorProp`, `addThermostatSetpointProp`, `addThermostatLowerSetpointProp`, `addThermostatUpperSetpointProp`, `addThermostatDualSetpointProp` and `addThermostatModeProp` (`AlexaThermostatMode`); `addProperty(row, place, value)` for a property without one. See Interface Types. -- A helper reports a value outside of the range of its property as the nearest value of the range and prints an error; 1.1.0 sent the value as it was. The brightness and the color temperature are taken as `int` (1.1.0: `unsigned int`), so that a negative number is reported as the lowest value. -- New: `AlexaAssets` and `AlexaUnits` in `src/AlexaResources.h`, the 103 asset ids of the catalog of Alexa as strings in program memory. `AlexaState` has the states `EcoOn`, `EcoOff`, `Low`, `Empty`, `Full`, `Done` and `Stuck` beside `Open` and `Closed`. `addStateMapping()` takes a number of any type, `addStateMapping({AlexaState::Closed}, 0)`. -- New: `AlexaCapability::isValid()`, `false` after a setter has refused what it was given. A capability that is not valid is left out of discovery with an error; before, it was announced without what had been refused. -- A temperature is reported in the scale it is given in: `AddTemperatureSensorProp(TemperatureSensorScale::FAHRENHEIT, 69)` reports 69 `FAHRENHEIT`, where 1.1.0 reported 20.56 `CELSIUS`. `TemperatureSensorScale::KELVIN` is new. -- `messageId` is a UUID of version 4 from the random source of the hardware. 1.1.0 sent 37 characters from `rand()`, seeded with the time in seconds: two messages of one second had the same id. -- A `ChangeReport` or an event that a handler sends is not taken for the answer to its directive. -- A directive that nothing answers is answered by the library with the ErrorResponse `INVALID_DIRECTIVE`: when the device has no handler for it, and when it names a capability that the device does not have and the handler sent nothing. 1.1.0 left both to the timeout, after which Alexa says that the device does not respond. A handler of `onDirective()` that sends nothing for a capability of its device prints an error; nothing is sent for it. A handler of `registerEvent()` may answer from a later `loop()`, so its silence is a line at `DEBUG`. -- `registerEvent()` with a name that has a handler replaces the handler; 1.1.0 kept the first and never called the second. An eleventh name (`MAX_EVENTS` is 10), and a call without a name or a function, is refused with an error; 1.1.0 dropped the eleventh without a word. -- Devices are a linked list, and a device holds its capabilities in an array of 8 pointers (`ALEX2ESP_MAX_CAPABILITIES`); both were a `std::deque`. A ninth capability is refused with an error and `addCapability()` returns `nullptr`. `getDevice()` returns `nullptr` with an error when the heap has no room for the device. An `AlexaDevice` cannot be copied. A directive whose topic names a device of the board and whose `endpointId` does not is ignored with a line at `DEBUG` (it was an error). -- A capability takes 116 bytes of heap and holds 3 friendly names, 4 action mappings and 2 state mappings (`ALEX2ESP_MAX_FRIENDLY_NAMES`, `ALEX2ESP_MAX_ACTION_MAPPINGS`, `ALEX2ESP_MAX_STATE_MAPPINGS`); one more is refused with an error. The instance and the text of a friendly name are copied, as before. The locale and the directive name and payload of an `ActionMapping` are kept as pointers, where 1.1.0 copied them: pass literals. An `ActionMapping` announces its actions in the order of `AlexaAction`. -- What Alexa does not accept is refused with an error that says what to change. `addCapability(row)` for an interface with instances and no instance, or with an instance for an interface without, adds nothing and returns `nullptr`. A capability with instances that has no instance or no friendly name when the device is announced (a 1.x sketch sets both after `addCapability(type)`) is left out of discovery. Action and state mappings are taken by `RangeController`, `ModeController` and `ToggleController` only. -- `PlaybackController` and `WakeOnLANController` are announced with `"properties": {}`, as their pages show them. -- Removed: `AlexaInterface::getJSON()` (`toJson()` adds the capability to the capabilities of its endpoint) and `getProps()` (the row has the properties), `ActionMapping::getJSON()` and the `String` and `std::vector` members of `ActionMapping`, the class `FriendlyName`. - -Memory: `examples/legacy/basicLight` for a D1 mini takes 30,520 bytes of static RAM (1.1.0: 52,768) and 333,989 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). 18,260 bytes of the static RAM were the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients; 3,720 were the names, versions and properties of all interfaces and the names of the display categories, which are in flash now. SNTP and the time stamp are 1.8 KB of the flash figure. The instance, names, configuration and mappings of a capability are 2.0 KB of it, the directive handler and the ErrorResponse 0.9 KB, the kinds of messages and the UUID 1.3 KB. A kind that a sketch does not send and a report helper that it does not call are not linked. With `-DALEX2ESP_LOG_MAX=0` the sketch takes 30,496 bytes of static RAM and 327,953 of flash, with `-DALEX2ESP_LOG_MAX=3` 30,520 and 334,477. - -Tests: `pio test -e native` in the repository runs 135 host tests: 52 of the receive and publish logic (reassembly of fragments, the directives that wait for `loop()`, repeated directives, the size limits, a heap without room, the wait between reconnects, topics, time stamps, the message id), 35 of discovery (the discovery object of every example against what 1.1.0 announced, the 28 rows against the interface pages, a type without a row, the discovery objects of a range, a mode and a toggle controller, a scene and a doorbell, what a capability refuses and that it is left out afterwards, the states and the asset ids), 16 of dispatch (the device and the capability a directive reaches, one handler for two devices, the handlers of 1.x, one that answers later, a second handler for a name and one more than a device keeps, a capability more than a device holds, the answers to a directive for a capability the device lacks and to one without a handler, the log level above what the build has and the level that prints nothing) and 32 of the messages (every kind against the JSON and the topic it is published with, every report helper against the JSON of its property, values outside of their range, the names of 1.x, the scale of a temperature, what `send()` and `sendAsync()` refuse). No board is needed. - -Packaging: `library.json`, `library.properties` and the `softwareVersion` and `firmwareVersion` that a device reports in discovery say 1.2.0; the library has the number in one place, `ALEX2ESP_VERSION` in `src/AlexaVersion.h`. The `platformio.ini` of the repository is for the host tests; a sketch does not need it. - -Boards that run 1.1.0 are not affected: the backend keeps the token topic and the HTTP routes. - -### 1.1.0 - -Behaviour changes: -- `begin()` now takes `(username, password, rootTopic)`, the order every example and this readme always used (and the order alex2node uses). 1.0.0 declared `(rootTopic, username, password)`, so a sketch written from the examples could not authenticate with the broker. -- Discovery answers go straight to MQTT (`/discover_r`), one object per device, all devices at once; if the MQTT client cannot take another object (free heap under 4 KB) the rest is sent from `loop()` as the client's queue drains, for up to 5 s. 1.0.0 pushed them one per `loop()` through a 5-slot HTTP queue and silently dropped the sixth device onwards, which the backend then removed from Alexa. -- `AlexaStatusMessage::send()` returns `bool`: an oversized report (over 2047 bytes) or a full queue is reported on Serial and dropped instead of being sent truncated. `AlexaUtils::enqueue()` refuses packets that would not fit instead of truncating them. -- Discovery JSON: `semantics` is emitted only when a capability has action mappings; an `ActionMapping` directive payload is emitted as a JSON object (parsed from the text you pass) or omitted when empty, instead of the string `"{}"`. -- `Response`/`StateReport` events include the required `"payload": {}`. -- Devices and capabilities are stored in `std::deque`; pointers from `getDevice()`/`addCapability()` no longer dangle once another device or capability is added. -- The MQTT payload is copied using its length (no write past the client's buffer); fragmented directive ids are ignored (a Discover is answered on its first fragment, its payload is unused). -- `begin()` no longer prints the MQTT credentials to Serial in debug builds. -- `DirectiveReceived` no longer prints "No event registered" when the sketch has not registered it. -- Removed: `Alex2ESP::messageSplitAndSend` (declared, never defined) and `AlexaStatusMessage::setEndpointId` (did nothing). -- Reported `softwareVersion`/`firmwareVersion` in the discovery attributes are now `1.1.0`. - -Examples: `WiFi.begin(WIFI_SSID, WIFI_PASSWORD)` (1.0.0 could only join open networks), `LED_BUILTIN` fallback, fixed brightness/colour-temperature Serial output, blinds use `DisplayCategory::INTERIOR_BLIND`. - -Packaging: `library.json` and `library.properties` restored with the Forgejo URL and the dependencies, `LICENSE` (MIT), a real `keywords.txt`, LF line endings (`.gitattributes`), ArduinoJson 7 deprecated calls replaced (warning-free build). ESP8266 is the supported target; ESP32 is untested. - ---- +`pio test -e native` in the repository runs 147 host tests: 52 of the receive and publish logic, 35 of discovery, 16 of dispatch, 32 of the messages and 12 of the examples (what each announces in discovery). No board and no broker is needed. The `platformio.ini` of the repository is for these tests; a sketch does not need it. ## Contributing -Feel free to submit pull requests or issues for feature requests and bug fixes. `pio test -e native` in the repository root runs the host tests (no board needed); they have to pass. ---- +Feel free to submit pull requests or issues for feature requests and bug fixes. The host tests have to pass, and the examples have to build without a warning. ## License + This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/test/readme/Configuration/Configuration.ino b/test/readme/Configuration/Configuration.ino new file mode 100644 index 0000000..81660f3 --- /dev/null +++ b/test/readme/Configuration/Configuration.ino @@ -0,0 +1,40 @@ +// The calls that readme.md shows under "Configuration", in a sketch that compiles. It is not an example and not +// a host test: it is built for d1_mini, with -DALEX2ESP_LOG_MAX=3, whenever that section of the readme changes. +#include +#include +#include + +const char *WIFI_SSID = ""; +const char *WIFI_PASSWORD = ""; + +const char *ALEXA_USERNAME = ""; +const char *ALEXA_PASSWORD = ""; +const char *ALEXA_ROOT_TOPIC = ""; + +Alex2ESP alexa; + +void setup() +{ + Serial.begin(74880); + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + // Another broker than alex2mqtt.stormysdream.club:1883. The text is not copied: a literal, or a buffer that stays + alexa.setServer("broker.example.org", 1883); + + // The sketch sets the clock itself; the library then leaves SNTP alone + alexa.setTimeSource(false); + + // NONE, ERROR, INFO (the default) or DEBUG; DEBUG needs -DALEX2ESP_LOG_MAX=3 + alexa.setLogLevel(AlexaLogLevel::DEBUG); + + // Where the lines go: any Print, Serial unless changed + AlexaLog::setOutput(&Serial); + + alexa.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); +} + +void loop() +{ + alexa.loop(); +}