AlexaCapability: instance, asset and text names, configuration, action and state mappings, nonControllable

AlexaCapability replaces the header-only AlexaInterface as what a device holds: 116 bytes with fixed places for
3 names, 4 action mappings and 2 state mappings, no std::vector or std::string. AlexaInterface and AlexaActions
stay as 1.x names; the five examples compile unchanged and announce the same bytes.
addCapability(row, instance) adds several capabilities of one generic controller. An instanced interface without
an instance or a name, an instance or semantics on an interface that takes none, and a full store are refused
with an ERROR line that says what to change.
PlaybackController and WakeOnLANController announce "properties": {} (AIF_EMPTY_PROPERTIES).
basicLight on a D1 mini: static RAM 30,788 -> 30,672 B, flash 330,509 -> 332,541 B; host tests 61 -> 77.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:33:57 +00:00
parent 8b11cc2394
commit 7afc4346e8
12 changed files with 1084 additions and 253 deletions

View file

@ -1,5 +1,6 @@
// Host tests of discovery: the object that each example announces on <root>/discover_r, compared byte by byte
// with what library 1.1.0 sent for the same sketch, and what the descriptor rows of src/AlexaInterfaces.cpp hold.
// with what library 1.1.0 sent for the same sketch, what the descriptor rows of src/AlexaInterfaces.cpp hold, and
// what a capability announces and refuses (src/AlexaCapability.cpp).
// The devices are built with the calls of the example's setup().
// pio test -e native
#include <unity.h>
@ -285,6 +286,343 @@ void test_display_categories_have_the_name_of_their_enumerator(void)
TEST_ASSERT_EQUAL_STRING("OTHER", alexaDisplayCategoryName(static_cast<DisplayCategory>(200)));
}
static std::string announcedBy(const AlexaDevice &device)
{
std::string announced;
serializeJson(device.getDeviceJSON(), announced);
return announced;
}
static void assertLogged(const CapturedLog &log, const char *expected)
{
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(log.text.c_str(), expected), log.text.c_str());
}
struct LiftRange
{
int minimum;
int maximum;
};
static void fillLiftConfiguration(JsonObject configuration, void *context)
{
const LiftRange *range = static_cast<const LiftRange *>(context);
JsonObject supportedRange = configuration["supportedRange"].to<JsonObject>();
supportedRange["minimumValue"] = range->minimum;
supportedRange["maximumValue"] = range->maximum;
supportedRange["precision"] = 1;
configuration["unitOfMeasure"] = "Alexa.Unit.Percent";
}
void test_range_controller_blind_is_announced_with_names_configuration_and_semantics(void)
{
static LiftRange range = {0, 100};
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::INTERIOR_BLIND);
AlexaCapability *lift = device.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
TEST_ASSERT_NOT_NULL(lift);
lift->addFriendlyAsset(PSTR("Alexa.Setting.Opening"))
.addFriendlyName("Lift", "en-US")
.setProactivelyReported(true)
.setConfiguration(fillLiftConfiguration, &range)
.addActionMapping(ActionMapping({AlexaAction::Close, AlexaAction::Lower}, "SetRangeValue", "{\"rangeValue\":0}"))
.addActionMapping(ActionMapping({AlexaAction::Raise, AlexaAction::Open}, "SetRangeValue", "{\"rangeValue\":100}"))
.addStateMapping({AlexaState::Closed}, 0.0f)
.addStateMapping({AlexaState::Open}, 1.0f, 100.0f);
assertDiscovery(
endpoint("Bedroom Blinds", "INTERIOR_BLIND",
"{\"interface\":\"Alexa.RangeController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":true,"
"\"supported\":[{\"name\":\"rangeValue\"}]},"
"\"semantics\":{\"actionMappings\":["
"{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Close\",\"Alexa.Actions.Lower\"],"
"\"directive\":{\"name\":\"SetRangeValue\",\"payload\":{\"rangeValue\":0}}},"
"{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Open\",\"Alexa.Actions.Raise\"],"
"\"directive\":{\"name\":\"SetRangeValue\",\"payload\":{\"rangeValue\":100}}}],"
"\"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\"}},"
"{\"@type\":\"text\",\"value\":{\"text\":\"Lift\",\"locale\":\"en-US\"}}]},"
"\"configuration\":{\"supportedRange\":{\"minimumValue\":0,\"maximumValue\":100,\"precision\":1},"
"\"unitOfMeasure\":\"Alexa.Unit.Percent\"}}"),
device);
}
static void fillPositionConfiguration(JsonObject configuration, void *)
{
configuration["ordered"] = false;
JsonArray supportedModes = configuration["supportedModes"].to<JsonArray>();
for (const char *position : {"Up", "Down"})
{
JsonObject mode = supportedModes.add<JsonObject>();
mode["value"] = std::string("Position.") + position;
JsonObject name = mode["modeResources"]["friendlyNames"].add<JsonObject>();
name["@type"] = "text";
name["value"]["text"] = position;
name["value"]["locale"] = "en-US";
}
}
void test_mode_controller_is_announced_with_its_modes_and_text_states(void)
{
AlexaDevice device("Garage Door", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::GARAGE_DOOR);
device.addCapability(AlexaInterfaces::ModeController, "Door.Position")
->addFriendlyAsset(PSTR("Alexa.Setting.Mode"))
.setConfiguration(fillPositionConfiguration)
.addActionMapping(ActionMapping({AlexaAction::Open}, "SetMode", "{\"mode\":\"Position.Up\"}"))
.addStateMapping({AlexaState::Closed}, "Position.Down");
assertDiscovery(
endpoint("Garage Door", "GARAGE_DOOR",
"{\"interface\":\"Alexa.ModeController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"mode\"}]},"
"\"semantics\":{\"actionMappings\":["
"{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Open\"],"
"\"directive\":{\"name\":\"SetMode\",\"payload\":{\"mode\":\"Position.Up\"}}}],"
"\"stateMappings\":["
"{\"@type\":\"StatesToValue\",\"states\":[\"Alexa.States.Closed\"],\"value\":\"Position.Down\"}]},"
"\"instance\":\"Door.Position\","
"\"capabilityResources\":{\"friendlyNames\":["
"{\"@type\":\"asset\",\"value\":{\"assetId\":\"Alexa.Setting.Mode\"}}]},"
"\"configuration\":{\"ordered\":false,\"supportedModes\":["
"{\"value\":\"Position.Up\",\"modeResources\":{\"friendlyNames\":["
"{\"@type\":\"text\",\"value\":{\"text\":\"Up\",\"locale\":\"en-US\"}}]}},"
"{\"value\":\"Position.Down\",\"modeResources\":{\"friendlyNames\":["
"{\"@type\":\"text\",\"value\":{\"text\":\"Down\",\"locale\":\"en-US\"}}]}}]}}"),
device);
}
void test_toggle_controller_that_only_reports_is_announced_as_non_controllable(void)
{
AlexaDevice device("Fan", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::FAN);
device.addCapability(AlexaInterfaces::ToggleController, "Fan.Oscillate")
->addFriendlyName("Oscillate", "en-US")
.addFriendlyName("Schwenken", "de-DE")
.setRetrievable(false)
.setNonControllable(true);
assertDiscovery(
endpoint("Fan", "FAN",
"{\"interface\":\"Alexa.ToggleController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":false,\"proactivelyReported\":false,"
"\"supported\":[{\"name\":\"toggleState\"}],\"nonControllable\":true},"
"\"instance\":\"Fan.Oscillate\","
"\"capabilityResources\":{\"friendlyNames\":["
"{\"@type\":\"text\",\"value\":{\"text\":\"Oscillate\",\"locale\":\"en-US\"}},"
"{\"@type\":\"text\",\"value\":{\"text\":\"Schwenken\",\"locale\":\"de-DE\"}}]}}"),
device);
}
void test_doorbell_is_announced_without_properties(void)
{
AlexaDevice device("Front Door", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::DOORBELL);
device.addCapability(AlexaInterfaces::DoorbellEventSource)->setProactivelyReported(true);
assertDiscovery(endpoint("Front Door", "DOORBELL",
"{\"interface\":\"Alexa.DoorbellEventSource\",\"version\":\"3\",\"type\":\"AlexaInterface\"}"),
device);
}
void test_playback_and_wake_on_lan_are_announced_with_empty_properties(void)
{
AlexaDevice device("Media PC", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::COMPUTER);
device.addCapability(AlexaInterfaces::PlaybackController);
device.addCapability(AlexaInterfaces::WakeOnLANController);
assertDiscovery(
endpoint("Media PC", "COMPUTER",
"{\"interface\":\"Alexa.PlaybackController\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"properties\":{}},"
"{\"interface\":\"Alexa.WakeOnLANController\",\"version\":\"3\",\"type\":\"AlexaInterface\",\"properties\":{}}"),
device);
}
void test_instance_and_friendly_name_are_copied(void)
{
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
{
std::string instance = "Blind.Tilt";
std::string name = "Tilt";
device.addCapability(AlexaInterfaces::RangeController, instance.c_str())->addFriendlyName(name.c_str(), "en-US");
instance.assign("overwritten");
name.assign("gone");
}
const std::string announced = announcedBy(device);
TEST_ASSERT_NOT_NULL(strstr(announced.c_str(), "\"instance\":\"Blind.Tilt\""));
TEST_ASSERT_NOT_NULL(strstr(announced.c_str(), "\"text\":\"Tilt\""));
}
void test_instances_of_one_interface_are_capabilities_of_their_own(void)
{
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
AlexaCapability *lift = device.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
AlexaCapability *tilt = device.addCapability(AlexaInterfaces::RangeController, "Blind.Tilt");
TEST_ASSERT_NOT_NULL(lift);
TEST_ASSERT_NOT_NULL(tilt);
TEST_ASSERT_TRUE(lift != tilt);
TEST_ASSERT_EQUAL_PTR(tilt, device.addCapability(AlexaInterfaces::RangeController, "Blind.Tilt"));
TEST_ASSERT_EQUAL_STRING("Blind.Lift", lift->getInstance());
// As a 1.x sketch asks for the capability it added by its type
TEST_ASSERT_EQUAL_PTR(lift, device.addCapability(AlexaInterfaceType::RANGE_CONTROLLER));
}
void test_directive_matches_by_namespace_and_for_instanced_interfaces_by_instance(void)
{
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
AlexaCapability *power = device.addCapability(AlexaInterfaces::PowerController);
AlexaCapability *lift = device.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
TEST_ASSERT_TRUE(power->matches("Alexa.PowerController", nullptr));
TEST_ASSERT_TRUE(power->matches("Alexa.PowerController", "Blind.Lift"));
TEST_ASSERT_FALSE(power->matches("Alexa.RangeController", nullptr));
TEST_ASSERT_FALSE(power->matches(nullptr, nullptr));
TEST_ASSERT_TRUE(lift->matches("Alexa.RangeController", "Blind.Lift"));
TEST_ASSERT_FALSE(lift->matches("Alexa.RangeController", "Blind.Tilt"));
TEST_ASSERT_FALSE(lift->matches("Alexa.RangeController", nullptr));
}
void test_instanced_row_without_an_instance_is_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaces::RangeController));
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaces::ModeController, ""));
assertLogged(log, "error: ESP-01: capability Alexa.RangeController not added: it needs an instance, "
"addCapability(row, \"Blind.Lift\")");
assertLogged(log, "error: ESP-01: capability Alexa.ModeController not added: it needs an instance");
assertDiscovery(endpoint("Bedroom Blinds", "OTHER", ""), device);
}
void test_instance_for_an_interface_without_instances_is_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Test Lamp", "root", "ESP-01");
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaces::PowerController, "Lamp.Power"));
assertLogged(log, "error: ESP-01: capability Alexa.PowerController Lamp.Power not added: "
"the interface takes no instance, leave it out");
assertDiscovery(endpoint("Test Lamp", "OTHER", ""), device);
}
void test_capability_added_by_type_without_an_instance_is_left_out_of_discovery(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
AlexaInterface *toggle = device.addCapability(AlexaInterfaceType::TOGGLE_CONTROLLER);
TEST_ASSERT_NOT_NULL(toggle);
toggle->addFriendlyName("Bedroom Blinds", "en-US");
assertDiscovery(endpoint("Bedroom Blinds", "OTHER", POWER), device);
assertLogged(log, "error: ESP-01: Alexa.ToggleController left out of discovery: it has no instance, "
"call setInstance(\"Blind.Lift\") on it");
}
void test_instanced_capability_without_a_friendly_name_is_left_out_of_discovery(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
device.addCapability(AlexaInterfaces::PowerController);
device.addCapability(AlexaInterfaces::ModeController, "Blind.Mode");
assertDiscovery(endpoint("Bedroom Blinds", "OTHER", POWER), device);
assertLogged(log, "error: ESP-01: Alexa.ModeController Blind.Mode left out of discovery: it has no name, "
"call addFriendlyName(\"Lift\", \"en-US\") on it");
}
void test_semantics_on_an_interface_that_takes_none_are_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Test Lamp", "root", "ESP-01");
device.addCapability(AlexaInterfaces::PowerController)
->addActionMapping(ActionMapping({AlexaAction::Open}, "TurnOn"))
.addStateMapping({AlexaState::Open}, "ON");
assertLogged(log, "error: Alexa.PowerController: action mapping not added: "
"only RangeController, ModeController and ToggleController take one");
assertLogged(log, "error: Alexa.PowerController: state mapping not added: "
"only RangeController, ModeController and ToggleController take one");
assertDiscovery(endpoint("Test Lamp", "OTHER", POWER), device);
}
void test_more_names_and_mappings_than_a_capability_holds_are_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
AlexaCapability *lift = device.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
for (int i = 0; i < ALEX2ESP_MAX_FRIENDLY_NAMES; i++)
{
lift->addFriendlyName("Lift", "en-US");
}
for (int i = 0; i < ALEX2ESP_MAX_ACTION_MAPPINGS; i++)
{
lift->addActionMapping(ActionMapping({AlexaAction::Open}, "SetRangeValue", "{\"rangeValue\":100}"));
}
for (int i = 0; i < ALEX2ESP_MAX_STATE_MAPPINGS; i++)
{
lift->addStateMapping({AlexaState::Open}, 100.0f);
}
const std::string full = announcedBy(device);
TEST_ASSERT_EQUAL_STRING("", log.text.c_str() + strlen("[Alex2ESP] ESP-01: capability Alexa.RangeController Blind.Lift added\r\n"));
lift->addFriendlyName("Shade", "en-US")
.addFriendlyAsset(PSTR("Alexa.Setting.Opening"))
.addActionMapping(ActionMapping({AlexaAction::Close}, "SetRangeValue", "{\"rangeValue\":0}"))
.addStateMapping({AlexaState::Closed}, 0.0f);
assertLogged(log, "error: Alexa.RangeController Blind.Lift: friendly name \"Shade\" not added: "
"it has 3 already (ALEX2ESP_MAX_FRIENDLY_NAMES)");
assertLogged(log, "error: Alexa.RangeController Blind.Lift: asset not added: it has 3 names already");
assertLogged(log, "error: Alexa.RangeController Blind.Lift: action mapping to SetRangeValue not added: "
"it has 4 already (ALEX2ESP_MAX_ACTION_MAPPINGS)");
assertLogged(log, "error: Alexa.RangeController Blind.Lift: state mapping not added: "
"it has 2 already (ALEX2ESP_MAX_STATE_MAPPINGS)");
TEST_ASSERT_EQUAL_STRING(full.c_str(), announcedBy(device).c_str());
}
void test_action_mapping_payload_that_is_no_object_is_left_out_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Bedroom Blinds", "root", "ESP-01");
device.addCapability(AlexaInterfaces::ToggleController, "Blind.Toggle")
->addFriendlyName("Blind", "en-US")
.addActionMapping(ActionMapping({AlexaAction::Close}, "TurnOn", "rangeValue: 0"));
const std::string announced = announcedBy(device);
TEST_ASSERT_NOT_NULL(strstr(announced.c_str(), "\"directive\":{\"name\":\"TurnOn\"}"));
assertLogged(log, "error: Alexa.ToggleController Blind.Toggle: payload of the action mapping to TurnOn left out: "
"it is not the JSON text of an object");
}
void test_actions_and_states_have_the_names_of_alexa(void)
{
TEST_ASSERT_EQUAL_STRING("Alexa.Actions.Open", alexaActionName(AlexaAction::Open));
TEST_ASSERT_EQUAL_STRING("Alexa.Actions.SetEcoOff", alexaActionName(AlexaAction::SetEcoOff));
TEST_ASSERT_EQUAL_STRING("Alexa.States.Closed", alexaStateName(AlexaState::Closed));
TEST_ASSERT_EQUAL_STRING("Alexa.Actions.Lower", AlexaActionsUtils::toString(AlexaActions::Lower).c_str());
}
int main(int, char **)
{
UNITY_BEGIN();
@ -303,5 +641,22 @@ int main(int, char **)
RUN_TEST(test_thermostat_announces_its_four_properties);
RUN_TEST(test_directive_gets_the_type_of_the_capability_it_names);
RUN_TEST(test_display_categories_have_the_name_of_their_enumerator);
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_toggle_controller_that_only_reports_is_announced_as_non_controllable);
RUN_TEST(test_doorbell_is_announced_without_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_instances_of_one_interface_are_capabilities_of_their_own);
RUN_TEST(test_directive_matches_by_namespace_and_for_instanced_interfaces_by_instance);
RUN_TEST(test_instanced_row_without_an_instance_is_refused_with_an_error);
RUN_TEST(test_instance_for_an_interface_without_instances_is_refused_with_an_error);
RUN_TEST(test_capability_added_by_type_without_an_instance_is_left_out_of_discovery);
RUN_TEST(test_instanced_capability_without_a_friendly_name_is_left_out_of_discovery);
RUN_TEST(test_semantics_on_an_interface_that_takes_none_are_refused_with_an_error);
RUN_TEST(test_more_names_and_mappings_than_a_capability_holds_are_refused_with_an_error);
RUN_TEST(test_action_mapping_payload_that_is_no_object_is_left_out_with_an_error);
RUN_TEST(test_actions_and_states_have_the_names_of_alexa);
return UNITY_END();
}