From 69b4d5989632df35ed5019cb6c3ce7e926d06ccc Mon Sep 17 00:00:00 2001 From: David Date: Mon, 28 Sep 2026 21:41:51 +0000 Subject: [PATCH] Examples as Arduino sketches: one folder per device type, each its own endpointId, Wi-Fi with a timeout Light, DimmableLight, ColorTemperatureLight, ColorLight, TemperatureSensor, ContactSensor, Blind, Thermostat, Lock, Scene, Doorbell and MultiDevice on the 2.0 API: addCapability(row, instance), one onDirective() handler, the report helpers, ChangeReports from loop(), a deferred answer for the lock. Wi-Fi is waited for 30 s, then the sketch says what to check and carries on. examples/README.md has what each sketch does, its limits and the measured sizes for d1_mini: Light takes 30,616 bytes of static RAM and 332,565 of flash; all 17 sketches build with 0 warnings. test/test_examples compares the discovery object of every new sketch: 147 host tests (were 135). The sketches were compiled, not run on a board. Co-Authored-By: Claude Fable 5.1 --- examples/Blind/Blind.ino | 138 ++++++++ examples/ColorLight/ColorLight.ino | 154 +++++++++ .../ColorTemperatureLight.ino | 156 +++++++++ examples/ContactSensor/ContactSensor.ino | 105 ++++++ examples/DimmableLight/DimmableLight.ino | 120 +++++++ examples/Doorbell/Doorbell.ino | 104 ++++++ examples/Light/Light.ino | 99 ++++++ examples/Lock/Lock.ino | 117 +++++++ examples/MultiDevice/MultiDevice.ino | 106 ++++++ examples/README.md | 114 +++++++ examples/Scene/Scene.ino | 104 ++++++ .../TemperatureSensor/TemperatureSensor.ino | 127 +++++++ examples/Thermostat/Thermostat.ino | 319 ++++++++++++++++++ readme.md | 2 +- test/test_examples/test_main.cpp | 297 ++++++++++++++++ 15 files changed, 2061 insertions(+), 1 deletion(-) create mode 100644 examples/Blind/Blind.ino create mode 100644 examples/ColorLight/ColorLight.ino create mode 100644 examples/ColorTemperatureLight/ColorTemperatureLight.ino create mode 100644 examples/ContactSensor/ContactSensor.ino create mode 100644 examples/DimmableLight/DimmableLight.ino create mode 100644 examples/Doorbell/Doorbell.ino create mode 100644 examples/Light/Light.ino create mode 100644 examples/Lock/Lock.ino create mode 100644 examples/MultiDevice/MultiDevice.ino create mode 100644 examples/README.md create mode 100644 examples/Scene/Scene.ino create mode 100644 examples/TemperatureSensor/TemperatureSensor.ino create mode 100644 examples/Thermostat/Thermostat.ino create mode 100644 test/test_examples/test_main.cpp diff --git a/examples/Blind/Blind.ino b/examples/Blind/Blind.ino new file mode 100644 index 0000000..4c6ae54 --- /dev/null +++ b/examples/Blind/Blind.ino @@ -0,0 +1,138 @@ +// Blind: a roller blind that Alexa opens, closes and sets to a position (Alexa.RangeController with semantics). +// +// The capability is one instance of the RangeController, "Blind.Lift": 0 is closed, 100 is open. Its semantics +// tell Alexa what "open", "close", "raise" and "lower" mean for it, and which values count as open and closed. +// SetRangeValue outside of 0 to 100 is answered with VALUE_OUT_OF_RANGE; AdjustRangeValue stops at the ends. +// +// The blind of this sketch is a number. moveTo() is the place for the motor. +#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 = ""; + +const char LIFT[] = "Blind.Lift"; + +Alex2ESP alexa; +int lift = 0; // percent open + +// 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()); + } +} + +void moveTo(int position) +{ + lift = position; + Serial.printf("[BLIND] %d percent open\n", lift); +} + +// 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); +} + +// The state of the blind: what the answer to a directive and the answer to ReportState carry +void sendState(AlexaStatusMessage message) +{ + message.addHealthProp(EndpointHealth::OK).addRangeControllerProp(LIFT, lift).send(); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + if (directive.is("SetRangeValue")) + { + 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; + } + moveTo(position); + } + else if (directive.is("AdjustRangeValue")) + { + moveTo(constrain(lift + (directive.payload["rangeValueDelta"] | 0), 0, 100)); + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This blind takes a position").send(); + return; + } + + sendState(directive.response()); +} + +void setup() +{ + Serial.begin(74880); + + 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 *blind = alexa.getDevice("Bedroom Blind", "esp-blind"); + blind->setDisplayCategory(DisplayCategory::INTERIOR_BLIND); + + 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); + + blind->addCapability(AlexaInterfaces::EndpointHealth); + blind->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/ColorLight/ColorLight.ino b/examples/ColorLight/ColorLight.ino new file mode 100644 index 0000000..d92021c --- /dev/null +++ b/examples/ColorLight/ColorLight.ino @@ -0,0 +1,154 @@ +// ColorLight: an RGB lamp (Alexa.PowerController, Alexa.BrightnessController, Alexa.ColorController). +// +// Alexa names a color by hue (0 to 360 degrees), saturation and brightness (0 to 1). The sketch keeps the three, +// turns them into red, green and blue and drives three PWM outputs. SetColor sets the brightness too; SetBrightness +// changes the brightness and leaves hue and saturation alone. +#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 = ""; + +const uint8_t RED_PIN = 14; // D5 on a Wemos D1 mini, high = lit +const uint8_t GREEN_PIN = 12; // D6 +const uint8_t BLUE_PIN = 13; // D7 + +Alex2ESP alexa; +bool lampOn = false; +float hue = 0; // degrees +float saturation = 0; // 0 to 1: 0 is white +int brightness = 100; // percent + +// 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()); + } +} + +// Hue, saturation and brightness as the share of red, green and blue, 0 to 100 each +void writeLamp() +{ + float value = lampOn ? brightness : 0; + float sector = hue / 60; + int whole = static_cast(sector) % 6; + float part = sector - static_cast(sector); + float low = value * (1 - saturation); + float falling = value * (1 - saturation * part); + float rising = value * (1 - saturation * (1 - part)); + + float red = value, green = value, blue = value; + switch (whole) + { + case 0: green = rising; blue = low; break; + case 1: red = falling; blue = low; break; + case 2: red = low; blue = rising; break; + case 3: red = low; green = falling; break; + case 4: red = rising; green = low; break; + default: green = low; blue = falling; break; + } + + analogWrite(RED_PIN, static_cast(red + 0.5f)); + analogWrite(GREEN_PIN, static_cast(green + 0.5f)); + analogWrite(BLUE_PIN, static_cast(blue + 0.5f)); +} + +// 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) + .addBrightnessControllerProp(brightness) + .addColorControllerProp(hue, saturation, brightness / 100.0f) + .send(); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + if (directive.is("TurnOn") || directive.is("TurnOff")) + { + lampOn = directive.is("TurnOn"); + } + else if (directive.is("SetBrightness")) + { + brightness = constrain(directive.payload["brightness"] | brightness, 0, 100); + } + else if (directive.is("AdjustBrightness")) + { + brightness = constrain(brightness + (directive.payload["brightnessDelta"] | 0), 0, 100); + } + else if (directive.is("SetColor")) + { + JsonVariantConst color = directive.payload["color"]; + hue = constrain(color["hue"] | hue, 0.0f, 360.0f); + saturation = constrain(color["saturation"] | saturation, 0.0f, 1.0f); + brightness = constrain(static_cast((color["brightness"] | brightness / 100.0f) * 100 + 0.5f), 0, 100); + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lamp switches, dims and changes its color").send(); + return; + } + + writeLamp(); + sendState(directive.response()); +} + +void setup() +{ + Serial.begin(74880); + pinMode(RED_PIN, OUTPUT); + pinMode(GREEN_PIN, OUTPUT); + pinMode(BLUE_PIN, OUTPUT); + analogWriteRange(100); + writeLamp(); + + 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("Color Lamp", "esp-color-light"); + lamp->setDisplayCategory(DisplayCategory::LIGHT); + lamp->addCapability(AlexaInterfaces::PowerController); + lamp->addCapability(AlexaInterfaces::BrightnessController); + lamp->addCapability(AlexaInterfaces::ColorController); + lamp->addCapability(AlexaInterfaces::EndpointHealth); + lamp->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/ColorTemperatureLight/ColorTemperatureLight.ino b/examples/ColorTemperatureLight/ColorTemperatureLight.ino new file mode 100644 index 0000000..21cae2d --- /dev/null +++ b/examples/ColorTemperatureLight/ColorTemperatureLight.ino @@ -0,0 +1,156 @@ +// ColorTemperatureLight: a white lamp with a warm and a cold channel (Alexa.PowerController, +// Alexa.BrightnessController, Alexa.ColorTemperatureController). +// +// Two PWM outputs drive the two LED channels; the color temperature decides how the brightness is shared between +// them. The lamp covers 2200 K to 7000 K: a value outside of it is set to the nearest the lamp has, and the answer +// reports what was set. "Alexa, make the lamp warmer" and "cooler" move to the next of the five steps below. +#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 = ""; + +const uint8_t WARM_PIN = 5; // D1 on a Wemos D1 mini, high = lit +const uint8_t COLD_PIN = 4; // D2 + +// Warm white, soft white, white, daylight white, cool white: the values Alexa sends for these names +const int STEPS[] = {2200, 2700, 4000, 5500, 7000}; +const int STEP_COUNT = sizeof(STEPS) / sizeof(STEPS[0]); + +Alex2ESP alexa; +bool lampOn = false; +int brightness = 100; // percent +int kelvin = 2700; + +// 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()); + } +} + +void writeLamp() +{ + int level = lampOn ? brightness : 0; + int cold = map(kelvin, STEPS[0], STEPS[STEP_COUNT - 1], 0, level); + analogWrite(COLD_PIN, cold); + analogWrite(WARM_PIN, level - cold); +} + +// 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) + .addBrightnessControllerProp(brightness) + .addColorTemperatureControllerProp(kelvin) + .send(); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + if (directive.is("TurnOn") || directive.is("TurnOff")) + { + lampOn = directive.is("TurnOn"); + } + else if (directive.is("SetBrightness")) + { + brightness = constrain(directive.payload["brightness"] | brightness, 0, 100); + } + else if (directive.is("AdjustBrightness")) + { + brightness = constrain(brightness + (directive.payload["brightnessDelta"] | 0), 0, 100); + } + else if (directive.is("SetColorTemperature")) + { + kelvin = constrain(directive.payload["colorTemperatureInKelvin"] | kelvin, STEPS[0], STEPS[STEP_COUNT - 1]); + } + else if (directive.is("IncreaseColorTemperature")) + { + for (int i = 0; i < STEP_COUNT; i++) + { + if (STEPS[i] > kelvin) + { + kelvin = STEPS[i]; + break; + } + } + } + else if (directive.is("DecreaseColorTemperature")) + { + for (int i = STEP_COUNT - 1; i >= 0; i--) + { + if (STEPS[i] < kelvin) + { + kelvin = STEPS[i]; + break; + } + } + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lamp switches, dims and changes its white").send(); + return; + } + + writeLamp(); + sendState(directive.response()); +} + +void setup() +{ + Serial.begin(74880); + pinMode(WARM_PIN, OUTPUT); + pinMode(COLD_PIN, OUTPUT); + analogWriteRange(100); + writeLamp(); + + 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("White Lamp", "esp-white-light"); + lamp->setDisplayCategory(DisplayCategory::LIGHT); + lamp->addCapability(AlexaInterfaces::PowerController); + lamp->addCapability(AlexaInterfaces::BrightnessController); + lamp->addCapability(AlexaInterfaces::ColorTemperatureController); + lamp->addCapability(AlexaInterfaces::EndpointHealth); + lamp->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/ContactSensor/ContactSensor.ino b/examples/ContactSensor/ContactSensor.ino new file mode 100644 index 0000000..2f805db --- /dev/null +++ b/examples/ContactSensor/ContactSensor.ino @@ -0,0 +1,105 @@ +// ContactSensor: a door or window contact that tells Alexa when it opens and closes (Alexa.ContactSensor). +// +// A reed switch connects the pin to ground while the door is closed; the internal pull-up makes the pin high when +// it opens. A level has to hold for 50 ms before it counts. Every change is sent as a ChangeReport; ReportState +// is answered with the state of the contact. A change while there is no session with the broker is not reported +// later: Alexa gets the state with its next ReportState. +#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 = ""; + +const uint8_t CONTACT_PIN = 14; // D5 on a Wemos D1 mini +const unsigned long DEBOUNCE_MS = 50; + +Alex2ESP alexa; +AlexaDevice *contact; + +bool isOpen = false; +bool lastLevel = false; +unsigned long lastEdge = 0; + +// 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()); + } +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + directive.stateReport().addHealthProp(EndpointHealth::OK).addContactSensorProp(isOpen).send(); + return; + } + + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "A contact takes no directives").send(); +} + +void setup() +{ + Serial.begin(74880); + pinMode(CONTACT_PIN, INPUT_PULLUP); + isOpen = lastLevel = digitalRead(CONTACT_PIN) == 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 + contact = alexa.getDevice("Back Door", "esp-contact"); + contact->setDisplayCategory(DisplayCategory::CONTACT_SENSOR); + contact->addCapability(AlexaInterfaces::ContactSensor)->setProactivelyReported(true); + contact->addCapability(AlexaInterfaces::EndpointHealth); + contact->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); + + bool level = digitalRead(CONTACT_PIN) == HIGH; + if (level != lastLevel) + { + lastLevel = level; + lastEdge = millis(); + } + + if (level != isOpen && millis() - lastEdge >= DEBOUNCE_MS) + { + isOpen = level; + Serial.println(isOpen ? "[CONTACT] open" : "[CONTACT] closed"); + contact->changeReport(AlexaCause::PHYSICAL_INTERACTION) + .addContactSensorProp(isOpen) + .context() + .addHealthProp(EndpointHealth::OK) + .send(); + } +} diff --git a/examples/DimmableLight/DimmableLight.ino b/examples/DimmableLight/DimmableLight.ino new file mode 100644 index 0000000..252130a --- /dev/null +++ b/examples/DimmableLight/DimmableLight.ino @@ -0,0 +1,120 @@ +// DimmableLight: a lamp that Alexa switches and dims (Alexa.PowerController, Alexa.BrightnessController). +// +// The lamp is the on-board LED, dimmed with PWM. SetBrightness sets a value of 0 to 100, AdjustBrightness changes +// it by a difference; the result stays within 0 to 100. +#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; +int brightness = 100; // percent + +// 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 pin is low for the part of the time the LED is lit +void writeLamp() +{ + analogWrite(LED_BUILTIN, lampOn ? 100 - brightness : 100); +} + +// 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) + .addBrightnessControllerProp(brightness) + .send(); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + if (directive.is("TurnOn") || directive.is("TurnOff")) + { + lampOn = directive.is("TurnOn"); + } + else if (directive.is("SetBrightness")) + { + brightness = constrain(directive.payload["brightness"] | brightness, 0, 100); + } + else if (directive.is("AdjustBrightness")) + { + brightness = constrain(brightness + (directive.payload["brightnessDelta"] | 0), 0, 100); + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lamp switches and dims").send(); + return; + } + + writeLamp(); + sendState(directive.response()); +} + +void setup() +{ + Serial.begin(74880); + pinMode(LED_BUILTIN, OUTPUT); + analogWriteRange(100); + writeLamp(); + + 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("Dimmable Lamp", "esp-dimmable-light"); + lamp->setDisplayCategory(DisplayCategory::LIGHT); + lamp->addCapability(AlexaInterfaces::PowerController); + lamp->addCapability(AlexaInterfaces::BrightnessController); + lamp->addCapability(AlexaInterfaces::EndpointHealth); + lamp->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/Doorbell/Doorbell.ino b/examples/Doorbell/Doorbell.ino new file mode 100644 index 0000000..42df252 --- /dev/null +++ b/examples/Doorbell/Doorbell.ino @@ -0,0 +1,104 @@ +// Doorbell: a button that tells Alexa when it is pressed (Alexa.DoorbellEventSource). +// +// The button connects the pin to ground; the internal pull-up makes the pin high while it is released. A level has +// to hold for 50 ms before it counts. Every press publishes a DoorbellPress event on /event. A press while +// there is no session with the broker is lost: the library prints why it was not sent. +// +// This event has not been tried with Alexa yet. +#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 = ""; + +const uint8_t BUTTON_PIN = 14; // D5 on a Wemos D1 mini +const unsigned long DEBOUNCE_MS = 50; + +Alex2ESP alexa; +AlexaDevice *doorbell; + +bool pressed = false; +bool lastLevel = false; +unsigned long lastEdge = 0; + +// 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()); + } +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + directive.stateReport().addHealthProp(EndpointHealth::OK).send(); + return; + } + + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "A doorbell takes no directives").send(); +} + +void setup() +{ + Serial.begin(74880); + pinMode(BUTTON_PIN, INPUT_PULLUP); + + 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 + doorbell = alexa.getDevice("Front Door Bell", "esp-doorbell"); + doorbell->setDisplayCategory(DisplayCategory::DOORBELL); + doorbell->addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true); + doorbell->addCapability(AlexaInterfaces::EndpointHealth); + doorbell->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); + + bool level = digitalRead(BUTTON_PIN) == LOW; + if (level != lastLevel) + { + lastLevel = level; + lastEdge = millis(); + } + + if (level != pressed && millis() - lastEdge >= DEBOUNCE_MS) + { + pressed = level; + if (pressed) + { + Serial.println("[DOORBELL] pressed"); + doorbell->doorbellPress().send(); + } + } +} diff --git a/examples/Light/Light.ino b/examples/Light/Light.ino new file mode 100644 index 0000000..f1db8b9 --- /dev/null +++ b/examples/Light/Light.ino @@ -0,0 +1,99 @@ +// 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(); +} diff --git a/examples/Lock/Lock.ino b/examples/Lock/Lock.ino new file mode 100644 index 0000000..a86a472 --- /dev/null +++ b/examples/Lock/Lock.ino @@ -0,0 +1,117 @@ +// Lock: a door lock whose bolt takes 3 s to move (Alexa.LockController), answered with a deferred response. +// +// Lock and Unlock start the bolt and are answered at once with a DeferredResponse that names 5 s. When the bolt +// has arrived, loop() sends the answer itself, with the correlationToken of the directive, which the sketch has +// kept. While the bolt moves, another Lock or Unlock is refused with ENDPOINT_BUSY. +// +// A switch from BLOCKED_PIN to ground stands for a bolt that cannot move: the answer then reports JAMMED. +#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 = ""; + +const uint8_t BOLT_PIN = 5; // D1 on a Wemos D1 mini: high = locked +const uint8_t BLOCKED_PIN = 14; // D5: low = the bolt is blocked +const unsigned long BOLT_MS = 3000; + +Alex2ESP alexa; +AlexaDevice *doorLock; + +AlexaLockState state = AlexaLockState::LOCKED; +AlexaLockState goal = AlexaLockState::LOCKED; +bool moving = false; +unsigned long movingSince = 0; +String token; // the correlationToken of the directive that is being carried out + +// 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()); + } +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + // While the bolt moves this is the state it left + directive.stateReport().addHealthProp(EndpointHealth::OK).addLockControllerProp(state).send(); + return; + } + + if (!directive.is("Lock") && !directive.is("Unlock")) + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lock locks and unlocks").send(); + return; + } + if (moving) + { + directive.error(AlexaErrorType::ENDPOINT_BUSY, "The bolt is moving").send(); + return; + } + + 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(); +} + +void setup() +{ + Serial.begin(74880); + pinMode(BOLT_PIN, OUTPUT); + pinMode(BLOCKED_PIN, INPUT_PULLUP); + digitalWrite(BOLT_PIN, 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 + doorLock = alexa.getDevice("Front Door Lock", "esp-lock"); + doorLock->setDisplayCategory(DisplayCategory::SMARTLOCK); + doorLock->addCapability(AlexaInterfaces::LockController); + doorLock->addCapability(AlexaInterfaces::EndpointHealth); + doorLock->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); + + 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(); + } +} diff --git a/examples/MultiDevice/MultiDevice.ino b/examples/MultiDevice/MultiDevice.ino new file mode 100644 index 0000000..59f17a5 --- /dev/null +++ b/examples/MultiDevice/MultiDevice.ino @@ -0,0 +1,106 @@ +// MultiDevice: two lamps on one board, two Alexa endpoints, one handler (Alexa.PowerController). +// +// Every device has its own endpoint id and name. Both get the same handler; the directive says which device it +// is for. +#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 = ""; + +const int LAMP_COUNT = 2; +const uint8_t LAMP_PINS[LAMP_COUNT] = {5, 4}; // D1 and D2 on a Wemos D1 mini, high = lit +const char *const LAMP_NAMES[LAMP_COUNT] = {"Left Lamp", "Right Lamp"}; +const char *const LAMP_IDS[LAMP_COUNT] = {"esp-multi-left", "esp-multi-right"}; + +Alex2ESP alexa; +AlexaDevice *lamps[LAMP_COUNT]; +bool lampOn[LAMP_COUNT] = {false, 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 one lamp: what the answer to a directive and the answer to ReportState carry +void sendState(AlexaStatusMessage message, int lamp) +{ + message.addHealthProp(EndpointHealth::OK) + .addPowerControllerProp(lampOn[lamp] ? PowerController::ON : PowerController::OFF) + .send(); +} + +void onDirective(AlexaDirective &directive) +{ + int lamp = directive.device == lamps[1] ? 1 : 0; + + if (directive.isReportState()) + { + sendState(directive.stateReport(), lamp); + return; + } + + if (directive.type == AlexaInterfaceType::POWER_CONTROLLER && (directive.is("TurnOn") || directive.is("TurnOff"))) + { + lampOn[lamp] = directive.is("TurnOn"); + digitalWrite(LAMP_PINS[lamp], lampOn[lamp] ? HIGH : LOW); + sendState(directive.response(), lamp); + return; + } + + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This lamp switches on and off").send(); +} + +void setup() +{ + Serial.begin(74880); + + connectWiFi(); + + // MQTT user name, MQTT password, root topic + alexa.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); + + for (int i = 0; i < LAMP_COUNT; i++) + { + pinMode(LAMP_PINS[i], OUTPUT); + digitalWrite(LAMP_PINS[i], LOW); + + // The name Alexa shows, and the id of the endpoint: every device of an account has its own + lamps[i] = alexa.getDevice(LAMP_NAMES[i], LAMP_IDS[i]); + lamps[i]->setDisplayCategory(DisplayCategory::LIGHT); + lamps[i]->addCapability(AlexaInterfaces::PowerController); + lamps[i]->addCapability(AlexaInterfaces::EndpointHealth); + lamps[i]->onDirective(onDirective); + } +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1844610 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,114 @@ +# Examples + +One folder per kind of device. Every sketch is complete: Wi-Fi, the MQTT session, one device (MultiDevice: two) with +its own endpoint id and name, and one handler for its directives. + +| Sketch | Device | Interfaces | Endpoint id | +|---|---|---|---| +| [Light](Light/Light.ino) | a lamp, on and off | PowerController | `esp-light` | +| [DimmableLight](DimmableLight/DimmableLight.ino) | a lamp with PWM brightness | PowerController, BrightnessController | `esp-dimmable-light` | +| [ColorTemperatureLight](ColorTemperatureLight/ColorTemperatureLight.ino) | a lamp with a warm and a cold channel, 2200 K to 7000 K | PowerController, BrightnessController, ColorTemperatureController | `esp-white-light` | +| [ColorLight](ColorLight/ColorLight.ino) | an RGB lamp | PowerController, BrightnessController, ColorController | `esp-color-light` | +| [TemperatureSensor](TemperatureSensor/TemperatureSensor.ino) | a thermometer; a ChangeReport after a change of more than 0.5 degrees, at most one a minute | TemperatureSensor | `esp-temperature` | +| [ContactSensor](ContactSensor/ContactSensor.ino) | a door contact on a GPIO; a ChangeReport on every change | ContactSensor | `esp-contact` | +| [Blind](Blind/Blind.ino) | a blind with a position of 0 to 100 and the words open, close, raise, lower | RangeController `Blind.Lift` with semantics | `esp-blind` | +| [Thermostat](Thermostat/Thermostat.ino) | HEAT, COOL, AUTO and OFF, one setpoint or two, the errors of the thermostat | ThermostatController, TemperatureSensor | `esp-thermostat` | +| [Lock](Lock/Lock.ino) | a bolt that takes 3 s: DeferredResponse, then the answer from `loop()` | LockController | `esp-lock` | +| [Scene](Scene/Scene.ino) | a scene that switches two outputs | SceneController | `esp-scene` | +| [Doorbell](Doorbell/Doorbell.ino) | a button on a GPIO; DoorbellPress on every press | DoorbellEventSource | `esp-doorbell` | +| [MultiDevice](MultiDevice/MultiDevice.ino) | two lamps, two endpoints, one handler | PowerController | `esp-multi-left`, `esp-multi-right` | + +Every device announces EndpointHealth as well and reports `connectivity` with its state. + +The sketches in [`legacy/`](legacy/) are the examples of 1.x, unchanged. They use `registerEvent()` and the +`Add...Prop()` names and are kept to show that a 1.x sketch compiles against 2.0. New sketches start from the ones +above. + +## Before flashing + +Fill in the five constants at the top of the sketch: the Wi-Fi network and the MQTT user name, password and root +topic of your Alex2MQTT account. + +```cpp +const char *WIFI_SSID = ""; +const char *WIFI_PASSWORD = ""; + +const char *ALEXA_USERNAME = ""; +const char *ALEXA_PASSWORD = ""; +const char *ALEXA_ROOT_TOPIC = ""; +``` + +Two boards with the same endpoint id on one account are one device to Alexa. Change the id and the name in +`getDevice()` when you flash a sketch a second time. + +Each sketch names the pins it uses by GPIO number, with the label of the Wemos D1 mini in a comment. The serial +output is at 74880 baud. + +## Wi-Fi + +`connectWiFi()` waits for the network for 30 s at the most. Without a connection it prints the status and the two +constants to check, and the sketch carries on: the ESP8266 keeps trying to join, and `alexa.loop()` opens the MQTT +session once Wi-Fi is up. + +## Building + +Arduino IDE: a sketch is a folder with an `.ino` of the same name, which is how the IDE opens it. + +PlatformIO: copy the `.ino` into `src/` of a project that has the library in `lib_deps`, or make the folder of the +sketch the source directory of the project (`src_dir`, or the environment variable of the same meaning). In a +project with a `d1_mini` environment and the library: + +``` +PLATFORMIO_SRC_DIR=/path/to/Alex2ESP/examples/Light pio run -e d1_mini +``` + +This is how the sizes below were built. The sketches define every function before it is used and include `Arduino.h`, so they are also valid as a `.cpp` +file. + +## Size + +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 this folder 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 | + +## What has been tried + +The sketches of this folder were compiled with PlatformIO, with 0 warnings. They have not been built with the +Arduino IDE and have not run on a board yet. What each announces +in discovery is checked by the host tests in `test/test_examples`. + +With this library on a Wemos D1 mini, a real Alexa account and other sketches (2026-09-28), Alexa discovered and +drove PowerController, BrightnessController, ColorTemperatureController and one instance each of ToggleController, +RangeController and ModeController. Not tried with Alexa from this library: ColorController, ThermostatController, +LockController and the deferred answer, SceneController, the ChangeReports and DoorbellPress. Voice commands have +not been tried. + +## Limits + +- Scene: the discovery object does not carry `supportsDeactivation`. The sketch answers `Deactivate`, but Alexa + may never send it. +- Doorbell, ContactSensor: an event or a change while there is no session with the broker is not sent later. +- Lock: the answer after the DeferredResponse is sent once. If the session is lost while the bolt moves, Alexa gets + the state with its next ReportState. +- Blind, Scene, Thermostat, the lamps: the state is kept in RAM and starts from the values in the sketch after a + reset. diff --git a/examples/Scene/Scene.ino b/examples/Scene/Scene.ino new file mode 100644 index 0000000..912bde3 --- /dev/null +++ b/examples/Scene/Scene.ino @@ -0,0 +1,104 @@ +// Scene: something Alexa starts by name, "Alexa, turn on Movie Night" (Alexa.SceneController). +// +// A scene has no state to report. Activate is answered with ActivationStarted in place of a Response, Deactivate +// with DeactivationStarted. The scene of this sketch switches two outputs: the ceiling lamp off and the lamp +// behind the screen on. +// +// The discovery object of the library does not say that the scene can be deactivated (supportsDeactivation), so +// Alexa may never send Deactivate. The handler answers it all the same. +#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 = ""; + +const uint8_t CEILING_PIN = 5; // D1 on a Wemos D1 mini, high = on +const uint8_t SCREEN_PIN = 4; // D2 + +Alex2ESP alexa; + +// 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()); + } +} + +void setScene(bool active) +{ + digitalWrite(CEILING_PIN, active ? LOW : HIGH); + digitalWrite(SCREEN_PIN, active ? HIGH : LOW); +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + directive.stateReport().addHealthProp(EndpointHealth::OK).send(); + return; + } + + if (directive.is("Activate")) + { + setScene(true); + directive.sceneStarted().send(); + } + else if (directive.is("Deactivate")) + { + setScene(false); + directive.sceneStopped().send(); + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "A scene is activated and deactivated").send(); + } +} + +void setup() +{ + Serial.begin(74880); + pinMode(CEILING_PIN, OUTPUT); + pinMode(SCREEN_PIN, OUTPUT); + setScene(false); + + 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 *scene = alexa.getDevice("Movie Night", "esp-scene"); + scene->setDisplayCategory(DisplayCategory::SCENE_TRIGGER); + scene->addCapability(AlexaInterfaces::SceneController); + scene->addCapability(AlexaInterfaces::EndpointHealth); + scene->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); +} diff --git a/examples/TemperatureSensor/TemperatureSensor.ino b/examples/TemperatureSensor/TemperatureSensor.ino new file mode 100644 index 0000000..89e4b43 --- /dev/null +++ b/examples/TemperatureSensor/TemperatureSensor.ino @@ -0,0 +1,127 @@ +// TemperatureSensor: a thermometer that Alexa reads, and that tells Alexa when the temperature changed +// (Alexa.TemperatureSensor). +// +// The sensor is a TMP36 on A0 of a Wemos D1 mini, whose A0 takes 0 to 3.2 V; for another sensor replace +// readTemperature(). The temperature is read every 2 s. A ChangeReport is sent when it differs by more than +// 0.5 degrees from what Alexa was told last, and at most once a minute. +#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 = ""; + +const unsigned long READ_EVERY_MS = 2000; +const unsigned long REPORT_EVERY_MS = 60000; +const float REPORT_DIFFERENCE = 0.5f; // degrees Celsius + +Alex2ESP alexa; +AlexaDevice *sensor; + +float temperature = 0; // degrees Celsius, as read last +float reported = 0; // what the last ChangeReport said +bool everReported = false; +unsigned long lastRead = 0; +unsigned long lastReport = 0; + +// 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()); + } +} + +// TMP36: 0.5 V at 0 degrees Celsius, 10 mV per degree +float readTemperature() +{ + float volts = analogRead(A0) * 3.2f / 1023; + return (volts - 0.5f) * 100; +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + directive.stateReport() + .addHealthProp(EndpointHealth::OK) + .addTemperatureSensorProp(temperature, TemperatureSensorScale::CELSIUS, millis() - lastRead) + .send(); + return; + } + + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "A thermometer takes no directives").send(); +} + +void reportChange() +{ + bool due = !everReported || millis() - lastReport >= REPORT_EVERY_MS; + if (!due || fabs(temperature - reported) <= REPORT_DIFFERENCE || alexa.getState() != Alex2ESPState::CONNECTED) + { + return; + } + + bool sent = sensor->changeReport(AlexaCause::PERIODIC_POLL) + .addTemperatureSensorProp(temperature) + .context() + .addHealthProp(EndpointHealth::OK) + .send(); + if (sent) + { + reported = temperature; + everReported = true; + lastReport = millis(); + } +} + +void setup() +{ + Serial.begin(74880); + temperature = readTemperature(); + + 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 + sensor = alexa.getDevice("Room Temperature", "esp-temperature"); + sensor->setDisplayCategory(DisplayCategory::TEMPERATURE_SENSOR); + sensor->addCapability(AlexaInterfaces::TemperatureSensor)->setProactivelyReported(true); + sensor->addCapability(AlexaInterfaces::EndpointHealth); + sensor->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); + + if (millis() - lastRead >= READ_EVERY_MS) + { + lastRead = millis(); + temperature = readTemperature(); + reportChange(); + } +} diff --git a/examples/Thermostat/Thermostat.ino b/examples/Thermostat/Thermostat.ino new file mode 100644 index 0000000..38378bb --- /dev/null +++ b/examples/Thermostat/Thermostat.ino @@ -0,0 +1,319 @@ +// Thermostat: heating and cooling with a setpoint (Alexa.ThermostatController, Alexa.TemperatureSensor). +// +// Modes HEAT, COOL, AUTO and OFF. HEAT and COOL have one setpoint; AUTO has two, it heats below the lower and +// cools above the upper one. Setpoints are 10 to 30 degrees Celsius and, in AUTO, at least 2 degrees apart. +// A directive in Fahrenheit or Kelvin is converted; the thermostat reports in Celsius. +// +// What is refused, and with which error: +// a setpoint outside of 10 to 30 degrees TEMPERATURE_VALUE_OUT_OF_RANGE, with the range +// two setpoints closer than 2 degrees REQUESTED_SETPOINTS_TOO_CLOSE, with the distance +// two setpoints in HEAT or COOL DUAL_SETPOINTS_UNSUPPORTED +// a setpoint while the thermostat is OFF THERMOSTAT_IS_OFF +// a mode other than the four UNSUPPORTED_THERMOSTAT_MODE +// +// The sensor is a TMP36 on A0 of a Wemos D1 mini (A0 takes 0 to 3.2 V); two outputs switch the heating and the +// cooling. +#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 = ""; + +const uint8_t HEAT_PIN = 5; // D1 on a Wemos D1 mini, high = on +const uint8_t COOL_PIN = 4; // D2 + +const float LOWEST = 10; // degrees Celsius +const float HIGHEST = 30; +const float DISTANCE = 2; // between the two setpoints of AUTO +const float HYSTERESIS = 0.5f; + +Alex2ESP alexa; +AlexaThermostatMode mode = AlexaThermostatMode::HEAT; +float target = 21; // HEAT and COOL +float lower = 19; // AUTO +float upper = 24; +float temperature = 0; +unsigned long lastRead = 0; + +// 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()); + } +} + +// TMP36: 0.5 V at 0 degrees Celsius, 10 mV per degree +float readTemperature() +{ + float volts = analogRead(A0) * 3.2f / 1023; + return (volts - 0.5f) * 100; +} + +// A temperature of a directive, {"value": 70, "scale": "FAHRENHEIT"}, in degrees Celsius +float toCelsius(JsonVariantConst temperature) +{ + float value = temperature["value"] | 0.0f; + const char *scale = temperature["scale"] | "CELSIUS"; + if (strcmp(scale, "FAHRENHEIT") == 0) + { + return (value - 32) * 5 / 9; + } + return strcmp(scale, "KELVIN") == 0 ? value - 273.15f : value; +} + +// A difference between two temperatures: one degree Fahrenheit is 5/9 of a degree Celsius +float differenceInCelsius(JsonVariantConst difference) +{ + float value = difference["value"] | 0.0f; + return strcmp(difference["scale"] | "CELSIUS", "FAHRENHEIT") == 0 ? value * 5 / 9 : value; +} + +void setCelsius(JsonObject temperature, float value) +{ + temperature["value"] = value; + temperature["scale"] = "CELSIUS"; +} + +bool withinRange(float setpoint) +{ + return setpoint >= LOWEST && setpoint <= HIGHEST; +} + +void sendOutOfRange(AlexaDirective &directive) +{ + AlexaStatusMessage error = directive.error(AlexaErrorType::TEMPERATURE_VALUE_OUT_OF_RANGE, "10 to 30 degrees Celsius"); + JsonObject range = error.payload()["validRange"].to(); + setCelsius(range["minimumValue"].to(), LOWEST); + setCelsius(range["maximumValue"].to(), HIGHEST); + error.send(); +} + +// The state of the thermostat: what the answer to a directive and the answer to ReportState carry +void sendState(AlexaStatusMessage message) +{ + message.addHealthProp(EndpointHealth::OK) + .addTemperatureSensorProp(temperature, TemperatureSensorScale::CELSIUS, millis() - lastRead) + .addThermostatModeProp(mode); + if (mode == AlexaThermostatMode::AUTO) + { + message.addThermostatDualSetpointProp(lower, upper); + } + else + { + message.addThermostatSetpointProp(target); + } + message.send(); +} + +// Takes the setpoints when the thermostat can use them and returns true; sends the error and returns false when not. +// One setpoint in AUTO moves both and keeps their distance. +bool setSetpoints(AlexaDirective &directive, bool two, float first, float second) +{ + if (mode == AlexaThermostatMode::OFF) + { + directive.error(AlexaErrorType::THERMOSTAT_IS_OFF, "Set a mode first").send(); + return false; + } + if (two && mode != AlexaThermostatMode::AUTO) + { + directive.error(AlexaErrorType::DUAL_SETPOINTS_UNSUPPORTED, "Two setpoints in AUTO only").send(); + return false; + } + if (!two && mode == AlexaThermostatMode::AUTO) + { + float half = (upper - lower) / 2; + second = first + half; + first = first - half; + } + + if (mode != AlexaThermostatMode::AUTO) + { + if (!withinRange(first)) + { + sendOutOfRange(directive); + return false; + } + target = first; + return true; + } + + if (!withinRange(first) || !withinRange(second)) + { + sendOutOfRange(directive); + return false; + } + if (second - first < DISTANCE) + { + AlexaStatusMessage error = directive.error(AlexaErrorType::REQUESTED_SETPOINTS_TOO_CLOSE, "At least 2 degrees apart"); + setCelsius(error.payload()["minimumTemperatureDelta"].to(), DISTANCE); + error.send(); + return false; + } + lower = first; + upper = second; + return true; +} + +bool setMode(const char *name) +{ + if (strcmp(name, "HEAT") == 0) + { + mode = AlexaThermostatMode::HEAT; + } + else if (strcmp(name, "COOL") == 0) + { + mode = AlexaThermostatMode::COOL; + } + else if (strcmp(name, "AUTO") == 0) + { + mode = AlexaThermostatMode::AUTO; + } + else if (strcmp(name, "OFF") == 0) + { + mode = AlexaThermostatMode::OFF; + } + else + { + return false; + } + return true; +} + +void onDirective(AlexaDirective &directive) +{ + if (directive.isReportState()) + { + sendState(directive.stateReport()); + return; + } + + JsonObjectConst payload = directive.payload; + if (directive.is("SetTargetTemperature")) + { + bool two = !payload["lowerSetpoint"].isNull() && !payload["upperSetpoint"].isNull(); + bool taken = two ? setSetpoints(directive, true, toCelsius(payload["lowerSetpoint"]), toCelsius(payload["upperSetpoint"])) + : setSetpoints(directive, false, toCelsius(payload["targetSetpoint"]), 0); + if (!taken) + { + return; + } + } + else if (directive.is("AdjustTargetTemperature")) + { + float centre = mode == AlexaThermostatMode::AUTO ? (lower + upper) / 2 : target; + if (!setSetpoints(directive, false, centre + differenceInCelsius(payload["targetSetpointDelta"]), 0)) + { + return; + } + } + else if (directive.is("SetThermostatMode")) + { + if (!setMode(payload["thermostatMode"]["value"] | "")) + { + directive.error(AlexaErrorType::UNSUPPORTED_THERMOSTAT_MODE, "HEAT, COOL, AUTO or OFF").send(); + return; + } + } + else + { + directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This thermostat takes setpoints and modes").send(); + return; + } + + sendState(directive.response()); +} + +// The "configuration" of the capability in discovery +void thermostatConfiguration(JsonObject configuration, void *) +{ + JsonArray modes = configuration["supportedModes"].to(); + modes.add("HEAT"); + modes.add("COOL"); + modes.add("AUTO"); + modes.add("OFF"); + configuration["supportsScheduling"] = false; +} + +// Switches on below the setpoint and off above it, with half a degree between the two +void control() +{ + bool heats = mode == AlexaThermostatMode::HEAT || mode == AlexaThermostatMode::AUTO; + bool cools = mode == AlexaThermostatMode::COOL || mode == AlexaThermostatMode::AUTO; + float heatBelow = mode == AlexaThermostatMode::AUTO ? lower : target; + float coolAbove = mode == AlexaThermostatMode::AUTO ? upper : target; + + if (!heats || temperature >= heatBelow + HYSTERESIS) + { + digitalWrite(HEAT_PIN, LOW); + } + else if (temperature <= heatBelow - HYSTERESIS) + { + digitalWrite(HEAT_PIN, HIGH); + } + + if (!cools || temperature <= coolAbove - HYSTERESIS) + { + digitalWrite(COOL_PIN, LOW); + } + else if (temperature >= coolAbove + HYSTERESIS) + { + digitalWrite(COOL_PIN, HIGH); + } +} + +void setup() +{ + Serial.begin(74880); + pinMode(HEAT_PIN, OUTPUT); + pinMode(COOL_PIN, OUTPUT); + temperature = readTemperature(); + + 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 *thermostat = alexa.getDevice("Hallway Thermostat", "esp-thermostat"); + thermostat->setDisplayCategory(DisplayCategory::THERMOSTAT); + thermostat->addCapability(AlexaInterfaces::ThermostatController)->setConfiguration(thermostatConfiguration); + thermostat->addCapability(AlexaInterfaces::TemperatureSensor); + thermostat->addCapability(AlexaInterfaces::EndpointHealth); + thermostat->onDirective(onDirective); +} + +void loop() +{ + alexa.loop(); + + if (millis() - lastRead >= 2000) + { + lastRead = millis(); + temperature = readTemperature(); + control(); + } +} diff --git a/readme.md b/readme.md index b4f7e1d..8604354 100644 --- a/readme.md +++ b/readme.md @@ -54,7 +54,7 @@ Then include the library in your project: #include ``` -Complete sketches are in [`examples/legacy/`](examples/legacy/), one folder each: `basicLight`, `lightWithBrightness`, `lightWithColorTemp`, `tempSensor` and `blindControl`. Every example joins Wi-Fi with `WiFi.begin(WIFI_SSID, WIFI_PASSWORD)`; fill in the SSID, the password and your Alex2MQTT credentials before flashing. +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. --- diff --git a/test/test_examples/test_main.cpp b/test/test_examples/test_main.cpp new file mode 100644 index 0000000..001dff0 --- /dev/null +++ b/test/test_examples/test_main.cpp @@ -0,0 +1,297 @@ +// Host tests of the example sketches of 2.0 (examples//.ino): the object each of them announces on +// /discover_r, compared byte by byte with the text below. +// The devices are built with the calls of the example's setup(). A change to the capabilities of an example is a +// change here. The examples of 1.x (examples/legacy) are in test/test_discovery. +// pio test -e native +#include +#include +#include +#include "AlexaDevice.h" +#include "AlexaLog.h" +#include "AlexaResources.h" +#include "AlexaVersion.h" + +static std::string endpoint(const char *endpointId, const char *friendlyName, const char *displayCategory, + const std::string &capabilities) +{ + return std::string("{\"endpointId\":\"") + endpointId + "\",\"friendlyName\":\"" + friendlyName + + "\",\"description\":\"Alexa2MQTT Default Device\",\"manufacturerName\":\"Alexa2MQTT\"," + "\"displayCategories\":[\"" + displayCategory + "\"]," + "\"additionalAttributes\":{\"manufacturer\":\"Alexa2MQTT\",\"model\":\"Alexa2MQTT\"," + "\"serialNumber\":\"ESP2Alex\",\"firmwareVersion\":\"" ALEX2ESP_VERSION "\"," + "\"softwareVersion\":\"" ALEX2ESP_VERSION "\",\"customIdentifier\":\"ESP2Alex\"}," + "\"capabilities\":[" + capabilities + "]}"; +} + +// A capability with one property that is not reported unasked +#define CAPABILITY(interface, version, property) \ + "{\"interface\":\"Alexa." interface "\",\"version\":\"" version "\",\"type\":\"AlexaInterface\"," \ + "\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"" property "\"}]}}" + +// The same, reported unasked with a ChangeReport +#define REPORTING_CAPABILITY(interface, version, property) \ + "{\"interface\":\"Alexa." interface "\",\"version\":\"" version "\",\"type\":\"AlexaInterface\"," \ + "\"properties\":{\"retrievable\":true,\"proactivelyReported\":true,\"supported\":[{\"name\":\"" property "\"}]}}" + +static const char POWER[] = CAPABILITY("PowerController", "3", "powerState"); +static const char BRIGHTNESS[] = CAPABILITY("BrightnessController", "3", "brightness"); +static const char COLOR_TEMPERATURE[] = CAPABILITY("ColorTemperatureController", "3", "colorTemperatureInKelvin"); +static const char COLOR[] = CAPABILITY("ColorController", "3", "color"); +static const char TEMPERATURE[] = CAPABILITY("TemperatureSensor", "3", "temperature"); +static const char LOCK[] = CAPABILITY("LockController", "3", "lockState"); +static const char HEALTH[] = CAPABILITY("EndpointHealth", "3.1", "connectivity"); + +static std::string list(std::initializer_list capabilities) +{ + std::string text; + for (const char *capability : capabilities) + { + text += text.empty() ? "" : ","; + text += capability; + } + return text; +} + +static void assertDiscovery(const std::string &expected, const AlexaDevice &device) +{ + std::string announced; + serializeJson(device.getDeviceJSON(), announced); + TEST_ASSERT_EQUAL_STRING(expected.c_str(), announced.c_str()); +} + +void setUp(void) +{ + AlexaLog::setOutput(nullptr); // the lines about added capabilities are not what is tested +} + +void tearDown(void) {} + +void test_light_discovery(void) +{ + AlexaDevice lamp("Desk Lamp", "root", "esp-light"); + lamp.setDisplayCategory(DisplayCategory::LIGHT); + lamp.addCapability(AlexaInterfaces::PowerController); + lamp.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-light", "Desk Lamp", "LIGHT", list({POWER, HEALTH})), lamp); +} + +void test_dimmable_light_discovery(void) +{ + AlexaDevice lamp("Dimmable Lamp", "root", "esp-dimmable-light"); + lamp.setDisplayCategory(DisplayCategory::LIGHT); + lamp.addCapability(AlexaInterfaces::PowerController); + lamp.addCapability(AlexaInterfaces::BrightnessController); + lamp.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-dimmable-light", "Dimmable Lamp", "LIGHT", list({POWER, BRIGHTNESS, HEALTH})), lamp); +} + +void test_color_temperature_light_discovery(void) +{ + AlexaDevice lamp("White Lamp", "root", "esp-white-light"); + lamp.setDisplayCategory(DisplayCategory::LIGHT); + lamp.addCapability(AlexaInterfaces::PowerController); + lamp.addCapability(AlexaInterfaces::BrightnessController); + lamp.addCapability(AlexaInterfaces::ColorTemperatureController); + lamp.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-white-light", "White Lamp", "LIGHT", + list({POWER, BRIGHTNESS, COLOR_TEMPERATURE, HEALTH})), + lamp); +} + +void test_color_light_discovery(void) +{ + AlexaDevice lamp("Color Lamp", "root", "esp-color-light"); + lamp.setDisplayCategory(DisplayCategory::LIGHT); + lamp.addCapability(AlexaInterfaces::PowerController); + lamp.addCapability(AlexaInterfaces::BrightnessController); + lamp.addCapability(AlexaInterfaces::ColorController); + lamp.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-color-light", "Color Lamp", "LIGHT", list({POWER, BRIGHTNESS, COLOR, HEALTH})), lamp); +} + +void test_temperature_sensor_discovery(void) +{ + AlexaDevice sensor("Room Temperature", "root", "esp-temperature"); + sensor.setDisplayCategory(DisplayCategory::TEMPERATURE_SENSOR); + sensor.addCapability(AlexaInterfaces::TemperatureSensor)->setProactivelyReported(true); + sensor.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-temperature", "Room Temperature", "TEMPERATURE_SENSOR", + list({REPORTING_CAPABILITY("TemperatureSensor", "3", "temperature"), HEALTH})), + sensor); +} + +void test_contact_sensor_discovery(void) +{ + AlexaDevice contact("Back Door", "root", "esp-contact"); + contact.setDisplayCategory(DisplayCategory::CONTACT_SENSOR); + contact.addCapability(AlexaInterfaces::ContactSensor)->setProactivelyReported(true); + contact.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-contact", "Back Door", "CONTACT_SENSOR", + list({REPORTING_CAPABILITY("ContactSensor", "3", "detectionState"), HEALTH})), + contact); +} + +static void liftConfiguration(JsonObject configuration, void *) +{ + JsonObject range = configuration["supportedRange"].to(); + range["minimumValue"] = 0; + range["maximumValue"] = 100; + range["precision"] = 1; + configuration["unitOfMeasure"] = FPSTR(AlexaUnits::Percent); +} + +void test_blind_discovery(void) +{ + static const char LIFT[] = "Blind.Lift"; + AlexaDevice blind("Bedroom Blind", "root", "esp-blind"); + blind.setDisplayCategory(DisplayCategory::INTERIOR_BLIND); + + 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); + + blind.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery( + endpoint("esp-blind", "Bedroom Blind", "INTERIOR_BLIND", + list({"{\"interface\":\"Alexa.RangeController\",\"version\":\"3\",\"type\":\"AlexaInterface\"," + "\"properties\":{\"retrievable\":true,\"proactivelyReported\":false," + "\"supported\":[{\"name\":\"rangeValue\"}]}," + "\"semantics\":{\"actionMappings\":[" + "{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Close\"]," + "\"directive\":{\"name\":\"SetRangeValue\",\"payload\":{\"rangeValue\":0}}}," + "{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Open\"]," + "\"directive\":{\"name\":\"SetRangeValue\",\"payload\":{\"rangeValue\":100}}}," + "{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Lower\"]," + "\"directive\":{\"name\":\"AdjustRangeValue\"," + "\"payload\":{\"rangeValueDelta\":-10,\"rangeValueDeltaDefault\":false}}}," + "{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Raise\"]," + "\"directive\":{\"name\":\"AdjustRangeValue\"," + "\"payload\":{\"rangeValueDelta\":10,\"rangeValueDeltaDefault\":false}}}]," + "\"stateMappings\":[" + "{\"@type\":\"StatesToValue\",\"states\":[\"Alexa.States.Closed\"],\"value\":0}," + "{\"@type\":\"StatesToRange\",\"states\":[\"Alexa.States.Open\"]," + "\"range\":{\"minimumValue\":1,\"maximumValue\":100}}]}," + "\"instance\":\"Blind.Lift\"," + "\"capabilityResources\":{\"friendlyNames\":[" + "{\"@type\":\"asset\",\"value\":{\"assetId\":\"Alexa.Setting.Opening\"}}]}," + "\"configuration\":{\"supportedRange\":{\"minimumValue\":0,\"maximumValue\":100,\"precision\":1}," + "\"unitOfMeasure\":\"Alexa.Unit.Percent\"}}", + HEALTH})), + blind); +} + +static void thermostatConfiguration(JsonObject configuration, void *) +{ + JsonArray modes = configuration["supportedModes"].to(); + modes.add("HEAT"); + modes.add("COOL"); + modes.add("AUTO"); + modes.add("OFF"); + configuration["supportsScheduling"] = false; +} + +void test_thermostat_discovery(void) +{ + AlexaDevice thermostat("Hallway Thermostat", "root", "esp-thermostat"); + thermostat.setDisplayCategory(DisplayCategory::THERMOSTAT); + thermostat.addCapability(AlexaInterfaces::ThermostatController)->setConfiguration(thermostatConfiguration); + thermostat.addCapability(AlexaInterfaces::TemperatureSensor); + thermostat.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery( + endpoint("esp-thermostat", "Hallway Thermostat", "THERMOSTAT", + list({"{\"interface\":\"Alexa.ThermostatController\",\"version\":\"3.2\",\"type\":\"AlexaInterface\"," + "\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[" + "{\"name\":\"targetSetpoint\"},{\"name\":\"lowerSetpoint\"},{\"name\":\"upperSetpoint\"}," + "{\"name\":\"thermostatMode\"}]}," + "\"configuration\":{\"supportedModes\":[\"HEAT\",\"COOL\",\"AUTO\",\"OFF\"]," + "\"supportsScheduling\":false}}", + TEMPERATURE, HEALTH})), + thermostat); +} + +void test_lock_discovery(void) +{ + AlexaDevice doorLock("Front Door Lock", "root", "esp-lock"); + doorLock.setDisplayCategory(DisplayCategory::SMARTLOCK); + doorLock.addCapability(AlexaInterfaces::LockController); + doorLock.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-lock", "Front Door Lock", "SMARTLOCK", list({LOCK, HEALTH})), doorLock); +} + +void test_scene_discovery(void) +{ + AlexaDevice scene("Movie Night", "root", "esp-scene"); + scene.setDisplayCategory(DisplayCategory::SCENE_TRIGGER); + scene.addCapability(AlexaInterfaces::SceneController); + scene.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint("esp-scene", "Movie Night", "SCENE_TRIGGER", + list({"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\"}", + HEALTH})), + scene); +} + +void test_doorbell_discovery(void) +{ + AlexaDevice doorbell("Front Door Bell", "root", "esp-doorbell"); + doorbell.setDisplayCategory(DisplayCategory::DOORBELL); + doorbell.addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true); + doorbell.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery( + endpoint("esp-doorbell", "Front Door Bell", "DOORBELL", + list({"{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\"}", + HEALTH})), + doorbell); +} + +void test_multi_device_discovery(void) +{ + const char *const names[] = {"Left Lamp", "Right Lamp"}; + const char *const ids[] = {"esp-multi-left", "esp-multi-right"}; + + for (int i = 0; i < 2; i++) + { + AlexaDevice lamp(names[i], "root", ids[i]); + lamp.setDisplayCategory(DisplayCategory::LIGHT); + lamp.addCapability(AlexaInterfaces::PowerController); + lamp.addCapability(AlexaInterfaces::EndpointHealth); + + assertDiscovery(endpoint(ids[i], names[i], "LIGHT", list({POWER, HEALTH})), lamp); + } +} + +int main(int, char **) +{ + UNITY_BEGIN(); + RUN_TEST(test_light_discovery); + RUN_TEST(test_dimmable_light_discovery); + RUN_TEST(test_color_temperature_light_discovery); + RUN_TEST(test_color_light_discovery); + RUN_TEST(test_temperature_sensor_discovery); + RUN_TEST(test_contact_sensor_discovery); + RUN_TEST(test_blind_discovery); + RUN_TEST(test_thermostat_discovery); + RUN_TEST(test_lock_discovery); + RUN_TEST(test_scene_discovery); + RUN_TEST(test_doorbell_discovery); + RUN_TEST(test_multi_device_discovery); + return UNITY_END(); +}