Announce a doorbell with proactivelyReported and a scene with supportsDeactivation

An interface without properties says beside its name what Alexa has to know
of it; the library left both out. With a board and Alexa on 2026-09-28 the
discovery of a doorbell was accepted (202), the endpoint was not listed, and
its DoorbellPress was answered with 500 INTERNAL_SERVICE_EXCEPTION.

DoorbellEventSource now always carries "proactivelyReported": true.
SceneController carries "supportsDeactivation", false until the sketch calls
setSupportsDeactivation(true); on another interface the call is refused and
logged. The Scene example says that it can be undone.

148 host tests. Light: static RAM 30,616 B, flash 332,705 B (+140 B).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 22:20:34 +00:00
parent 1b8a5ff806
commit c3d02f462e
11 changed files with 65 additions and 18 deletions

View file

@ -76,7 +76,7 @@ void setup()
// The name Alexa shows, and the id of the endpoint: every device of an account has its own // 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 = alexa.getDevice("Front Door Bell", "esp-doorbell");
doorbell->setDisplayCategory(DisplayCategory::DOORBELL); doorbell->setDisplayCategory(DisplayCategory::DOORBELL);
doorbell->addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true); doorbell->addCapability(AlexaInterfaces::DoorbellEventSource);
doorbell->addCapability(AlexaInterfaces::EndpointHealth); doorbell->addCapability(AlexaInterfaces::EndpointHealth);
doorbell->onDirective(onDirective); doorbell->onDirective(onDirective);
} }

View file

@ -105,8 +105,6 @@ not been tried.
## Limits ## 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. - 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 - 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. the state with its next ReportState.

View file

@ -4,8 +4,8 @@
// with DeactivationStarted. The scene of this sketch switches two outputs: the ceiling lamp off and the lamp // with DeactivationStarted. The scene of this sketch switches two outputs: the ceiling lamp off and the lamp
// behind the screen on. // behind the screen on.
// //
// The discovery object of the library does not say that the scene can be deactivated (supportsDeactivation), so // setSupportsDeactivation(true) tells Alexa that the scene can be undone: "Alexa, turn off Movie Night" then
// Alexa may never send Deactivate. The handler answers it all the same. // arrives as Deactivate.
#include <Arduino.h> #include <Arduino.h>
#include <ESP8266WiFi.h> #include <ESP8266WiFi.h>
#include <Alex2ESP.h> #include <Alex2ESP.h>
@ -93,7 +93,7 @@ void setup()
// The name Alexa shows, and the id of the endpoint: every device of an account has its own // 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"); AlexaDevice *scene = alexa.getDevice("Movie Night", "esp-scene");
scene->setDisplayCategory(DisplayCategory::SCENE_TRIGGER); scene->setDisplayCategory(DisplayCategory::SCENE_TRIGGER);
scene->addCapability(AlexaInterfaces::SceneController); scene->addCapability(AlexaInterfaces::SceneController)->setSupportsDeactivation(true);
scene->addCapability(AlexaInterfaces::EndpointHealth); scene->addCapability(AlexaInterfaces::EndpointHealth);
scene->onDirective(onDirective); scene->onDirective(onDirective);
} }

View file

@ -104,6 +104,7 @@ addStateMapping KEYWORD2
addFriendlyAsset KEYWORD2 addFriendlyAsset KEYWORD2
setConfiguration KEYWORD2 setConfiguration KEYWORD2
setNonControllable KEYWORD2 setNonControllable KEYWORD2
setSupportsDeactivation KEYWORD2
isNonControllable KEYWORD2 isNonControllable KEYWORD2
getInstance KEYWORD2 getInstance KEYWORD2
getRow KEYWORD2 getRow KEYWORD2

View file

@ -685,7 +685,6 @@ What changes for a 1.x sketch that is compiled against 2.0:
- 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. - 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. - 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 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. - 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. - 28 of the interfaces of Alexa have a row. The others cannot be announced.
- The Arduino IDE builds with the default limits. - The Arduino IDE builds with the default limits.

View file

@ -88,7 +88,7 @@ AlexaCapabilityName::AlexaCapabilityName(const AlexaInterfaceDesc &row, const ch
} }
AlexaCapability::AlexaCapability(const AlexaInterfaceDesc &row, const char *instance) AlexaCapability::AlexaCapability(const AlexaInterfaceDesc &row, const char *instance)
: row(&row), retrievable(true), proactivelyReported(false), nonControllable(false), valid(true) : row(&row), retrievable(true), proactivelyReported(false), nonControllable(false), deactivation(false), valid(true)
{ {
setInstance(instance); setInstance(instance);
} }
@ -185,6 +185,18 @@ AlexaCapability &AlexaCapability::setNonControllable(bool value)
return *this; return *this;
} }
AlexaCapability &AlexaCapability::setSupportsDeactivation(bool value)
{
if (!row->has(AIF_DEACTIVATION))
{
const AlexaCapabilityName name(*row, instance);
ALEX2ESP_LOGE("%s: setSupportsDeactivation() ignored: only a SceneController can be deactivated", name.text);
return refused();
}
deactivation = value;
return *this;
}
AlexaCapability &AlexaCapability::setConfiguration(AlexaConfigurationFiller fill, void *context) AlexaCapability &AlexaCapability::setConfiguration(AlexaConfigurationFiller fill, void *context)
{ {
fillConfiguration = fill; fillConfiguration = fill;
@ -354,6 +366,17 @@ bool AlexaCapability::toJson(JsonArray capabilities, const char *endpointId) con
properties[F("nonControllable")] = true; properties[F("nonControllable")] = true;
} }
} }
// An interface without properties says beside its name what Alexa has to know of it. Without these Alexa
// accepts the discovery and leaves the endpoint out: a doorbell announced without proactivelyReported was not
// listed, and its DoorbellPress was answered with an error.
if (row->has(AIF_EVENTS))
{
capability[F("proactivelyReported")] = true;
}
if (row->has(AIF_DEACTIVATION))
{
capability[F("supportsDeactivation")] = static_cast<bool>(deactivation);
}
writeSemantics(capability); writeSemantics(capability);
if (instance != nullptr) if (instance != nullptr)

View file

@ -128,6 +128,10 @@ public:
// true: Alexa reports the state and sends no directive that changes it // true: Alexa reports the state and sends no directive that changes it
AlexaCapability &setNonControllable(bool value); AlexaCapability &setNonControllable(bool value);
// For a scene: true when it can be undone, and Alexa then sends Deactivate as well as Activate. A scene is
// announced with "supportsDeactivation": false until this is called.
AlexaCapability &setSupportsDeactivation(bool value);
AlexaCapability &setConfiguration(AlexaConfigurationFiller fill, void *context = nullptr); AlexaCapability &setConfiguration(AlexaConfigurationFiller fill, void *context = nullptr);
// Only for an interface that takes semantics: RangeController, ModeController, ToggleController // Only for an interface that takes semantics: RangeController, ModeController, ToggleController
@ -200,6 +204,7 @@ private:
bool retrievable : 1; bool retrievable : 1;
bool proactivelyReported : 1; bool proactivelyReported : 1;
bool nonControllable : 1; bool nonControllable : 1;
bool deactivation : 1;
bool valid : 1; bool valid : 1;
}; };

View file

@ -103,10 +103,10 @@ ALEXA_ROW(ThermostatController, NS_THERMOSTAT, VERSION_3_2, PROPS_THERMOSTAT, 4,
ALEXA_ROW(TemperatureSensor, NS_TEMPERATURE_SENSOR, VERSION_3, PROPS_TEMPERATURE_SENSOR, 1, AIF_NONE, TEMPERATURE_SENSOR); ALEXA_ROW(TemperatureSensor, NS_TEMPERATURE_SENSOR, VERSION_3, PROPS_TEMPERATURE_SENSOR, 1, AIF_NONE, TEMPERATURE_SENSOR);
ALEXA_ROW(HumiditySensor, NS_HUMIDITY_SENSOR, VERSION_3, PROPS_HUMIDITY_SENSOR, 1, AIF_NONE, HUMIDITY_SENSOR); ALEXA_ROW(HumiditySensor, NS_HUMIDITY_SENSOR, VERSION_3, PROPS_HUMIDITY_SENSOR, 1, AIF_NONE, HUMIDITY_SENSOR);
ALEXA_ROW(LockController, NS_LOCK, VERSION_3, PROPS_LOCK, 1, AIF_NONE, LOCK_CONTROLLER); ALEXA_ROW(LockController, NS_LOCK, VERSION_3, PROPS_LOCK, 1, AIF_NONE, LOCK_CONTROLLER);
ALEXA_ROW(SceneController, NS_SCENE, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES, SCENE_CONTROLLER); ALEXA_ROW(SceneController, NS_SCENE, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES | AIF_DEACTIVATION, SCENE_CONTROLLER);
ALEXA_ROW(ContactSensor, NS_CONTACT_SENSOR, VERSION_3, PROPS_DETECTION, 1, AIF_NONE, CONTACT_SENSOR); ALEXA_ROW(ContactSensor, NS_CONTACT_SENSOR, VERSION_3, PROPS_DETECTION, 1, AIF_NONE, CONTACT_SENSOR);
ALEXA_ROW(MotionSensor, NS_MOTION_SENSOR, VERSION_3, PROPS_DETECTION, 1, AIF_NONE, MOTION_SENSOR); ALEXA_ROW(MotionSensor, NS_MOTION_SENSOR, VERSION_3, PROPS_DETECTION, 1, AIF_NONE, MOTION_SENSOR);
ALEXA_ROW(DoorbellEventSource, NS_DOORBELL, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES, DOORBELL_EVENT_SOURCE); ALEXA_ROW(DoorbellEventSource, NS_DOORBELL, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES | AIF_EVENTS, DOORBELL_EVENT_SOURCE);
ALEXA_ROW(TimeHoldController, NS_TIME_HOLD, VERSION_3, PROPS_TIME_HOLD, 2, AIF_NONE, TIME_HOLD_CONTROLLER); ALEXA_ROW(TimeHoldController, NS_TIME_HOLD, VERSION_3, PROPS_TIME_HOLD, 2, AIF_NONE, TIME_HOLD_CONTROLLER);
ALEXA_ROW(Speaker, NS_SPEAKER, VERSION_3, PROPS_SPEAKER, 2, AIF_NONE, SPEAKER); ALEXA_ROW(Speaker, NS_SPEAKER, VERSION_3, PROPS_SPEAKER, 2, AIF_NONE, SPEAKER);
ALEXA_ROW(StepSpeaker, NS_STEP_SPEAKER, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES, STEP_SPEAKER); ALEXA_ROW(StepSpeaker, NS_STEP_SPEAKER, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES, STEP_SPEAKER);

View file

@ -91,7 +91,9 @@ enum AlexaInterfaceFlags : uint8_t
AIF_INSTANCED = 1, // a device may have several: each needs an instance and a friendly name AIF_INSTANCED = 1, // a device may have several: each needs an instance and a friendly name
AIF_NO_PROPERTIES = 2, // the discovery object has no "properties" (scenes, doorbells, step speakers) AIF_NO_PROPERTIES = 2, // the discovery object has no "properties" (scenes, doorbells, step speakers)
AIF_SEMANTICS = 4, // may carry action and state mappings AIF_SEMANTICS = 4, // may carry action and state mappings
AIF_EMPTY_PROPERTIES = 8 // the discovery object has "properties": {}, as the page of the interface shows it AIF_EMPTY_PROPERTIES = 8, // the discovery object has "properties": {}, as the page of the interface shows it
AIF_EVENTS = 16, // what it reports are events: "proactivelyReported": true beside the interface (doorbell)
AIF_DEACTIVATION = 32 // says whether it can be undone: "supportsDeactivation" beside the interface (scene)
}; };
// 16 bytes in program memory. The ESP8266 reads flash in whole words only: the pointers can be read as they are, // 16 bytes in program memory. The ESP8266 reads flash in whole words only: the pointers can be read as they are,

View file

@ -247,7 +247,7 @@ void test_scene_is_announced_without_properties(void)
device.addCapability(AlexaInterfaces::SceneController); device.addCapability(AlexaInterfaces::SceneController);
assertDiscovery(endpoint("Movie Night", "SCENE_TRIGGER", assertDiscovery(endpoint("Movie Night", "SCENE_TRIGGER",
"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\"}"), "{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"supportsDeactivation\":false}"),
device); device);
} }
@ -421,14 +421,32 @@ void test_toggle_controller_that_only_reports_is_announced_as_non_controllable(v
device); device);
} }
// alexa-scenecontroller.html: supportsDeactivation says whether Alexa may send Deactivate
void test_scene_that_can_be_undone_says_so_and_no_other_interface_can(void)
{
AlexaDevice device("Movie Night", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::SCENE_TRIGGER);
device.addCapability(AlexaInterfaces::SceneController)->setSupportsDeactivation(true);
assertDiscovery(endpoint("Movie Night", "SCENE_TRIGGER",
"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"supportsDeactivation\":true}"),
device);
AlexaDevice lamp("Lamp", "root", "ESP-02");
AlexaCapability *power = lamp.addCapability(AlexaInterfaces::PowerController);
TEST_ASSERT_TRUE(power->isValid());
power->setSupportsDeactivation(true);
TEST_ASSERT_FALSE_MESSAGE(power->isValid(), "a call the interface cannot take is a refused call");
}
void test_doorbell_is_announced_without_properties(void) void test_doorbell_is_announced_without_properties(void)
{ {
AlexaDevice device("Front Door", "root", "ESP-01"); AlexaDevice device("Front Door", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::DOORBELL); device.setDisplayCategory(DisplayCategory::DOORBELL);
device.addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true); device.addCapability(AlexaInterfaces::DoorbellEventSource);
assertDiscovery(endpoint("Front Door", "DOORBELL", assertDiscovery(endpoint("Front Door", "DOORBELL",
"{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\"}"), "{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"proactivelyReported\":true}"),
device); device);
} }
@ -769,6 +787,7 @@ int main(int, char **)
RUN_TEST(test_range_controller_blind_is_announced_with_names_configuration_and_semantics); RUN_TEST(test_range_controller_blind_is_announced_with_names_configuration_and_semantics);
RUN_TEST(test_mode_controller_is_announced_with_its_modes_and_text_states); RUN_TEST(test_mode_controller_is_announced_with_its_modes_and_text_states);
RUN_TEST(test_toggle_controller_that_only_reports_is_announced_as_non_controllable); RUN_TEST(test_toggle_controller_that_only_reports_is_announced_as_non_controllable);
RUN_TEST(test_scene_that_can_be_undone_says_so_and_no_other_interface_can);
RUN_TEST(test_doorbell_is_announced_without_properties); RUN_TEST(test_doorbell_is_announced_without_properties);
RUN_TEST(test_playback_and_wake_on_lan_are_announced_with_empty_properties); RUN_TEST(test_playback_and_wake_on_lan_are_announced_with_empty_properties);
RUN_TEST(test_instance_and_friendly_name_are_copied); RUN_TEST(test_instance_and_friendly_name_are_copied);

View file

@ -239,11 +239,11 @@ void test_scene_discovery(void)
{ {
AlexaDevice scene("Movie Night", "root", "esp-scene"); AlexaDevice scene("Movie Night", "root", "esp-scene");
scene.setDisplayCategory(DisplayCategory::SCENE_TRIGGER); scene.setDisplayCategory(DisplayCategory::SCENE_TRIGGER);
scene.addCapability(AlexaInterfaces::SceneController); scene.addCapability(AlexaInterfaces::SceneController)->setSupportsDeactivation(true);
scene.addCapability(AlexaInterfaces::EndpointHealth); scene.addCapability(AlexaInterfaces::EndpointHealth);
assertDiscovery(endpoint("esp-scene", "Movie Night", "SCENE_TRIGGER", assertDiscovery(endpoint("esp-scene", "Movie Night", "SCENE_TRIGGER",
list({"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\"}", list({"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"supportsDeactivation\":true}",
HEALTH})), HEALTH})),
scene); scene);
} }
@ -252,12 +252,12 @@ void test_doorbell_discovery(void)
{ {
AlexaDevice doorbell("Front Door Bell", "root", "esp-doorbell"); AlexaDevice doorbell("Front Door Bell", "root", "esp-doorbell");
doorbell.setDisplayCategory(DisplayCategory::DOORBELL); doorbell.setDisplayCategory(DisplayCategory::DOORBELL);
doorbell.addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true); doorbell.addCapability(AlexaInterfaces::DoorbellEventSource);
doorbell.addCapability(AlexaInterfaces::EndpointHealth); doorbell.addCapability(AlexaInterfaces::EndpointHealth);
assertDiscovery( assertDiscovery(
endpoint("esp-doorbell", "Front Door Bell", "DOORBELL", endpoint("esp-doorbell", "Front Door Bell", "DOORBELL",
list({"{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\"}", list({"{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"proactivelyReported\":true}",
HEALTH})), HEALTH})),
doorbell); doorbell);
} }