Describe interfaces with PROGMEM descriptor rows; drop the switch tables

AlexaInterfaces.h/.cpp hold one row per interface in program memory (28: namespace,
version, property names, flags, type), each row and string an object of its own, so
the linker keeps only the rows a sketch names. addCapability(AlexaInterfaceType) is
an always-inline switch that folds to one row; a type without a row is refused with
an error. The Event handler gets its type from the capabilities of the device.
Versions follow the interface pages (EndpointHealth 3.1, ThermostatController 3.2),
HumiditySensor is new, the two withdrawn interfaces and AlexaInterfaceUtils are gone.
Display category names are in flash. The discovery objects of the five examples are
unchanged: 14 new host tests compare them with what the previous commit announced.
basicLight on a D1 mini: static RAM 34,508 -> 30,788, flash 338,533 -> 330,509;
it links two rows, EndpointHealth and PowerController. Host tests: 47 -> 61.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:04:14 +00:00
parent d534f23f27
commit 8b11cc2394
13 changed files with 909 additions and 777 deletions

View file

@ -0,0 +1,307 @@
// 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.
// The devices are built with the calls of the example's setup().
// pio test -e native
#include <unity.h>
#include <ArduinoJson.h>
#include <string.h>
#include <string>
#include "AlexaDevice.h"
#include "AlexaLog.h"
#include "AlexaVersion.h"
// The endpoint id and the defaults are the same in every example; its name, category and capabilities are not
static std::string endpoint(const char *friendlyName, const char *displayCategory, const std::string &capabilities)
{
return std::string("{\"endpointId\":\"ESP-01\",\"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 + "]}";
}
static const char POWER[] =
"{\"interface\":\"Alexa.PowerController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"powerState\"}]}}";
static const char BRIGHTNESS[] =
"{\"interface\":\"Alexa.BrightnessController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"brightness\"}]}}";
static const char COLOR_TEMPERATURE[] =
"{\"interface\":\"Alexa.ColorTemperatureController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,"
"\"supported\":[{\"name\":\"colorTemperatureInKelvin\"}]}}";
static const char TEMPERATURE[] =
"{\"interface\":\"Alexa.TemperatureSensor\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"temperature\"}]}}";
static const char BLIND_TOGGLE[] =
"{\"interface\":\"Alexa.ToggleController\",\"version\":\"3\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":[{\"name\":\"toggleState\"}]},"
"\"semantics\":{\"actionMappings\":["
"{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Close\"],\"directive\":{\"name\":\"TurnOn\"}},"
"{\"@type\":\"ActionsToDirective\",\"actions\":[\"Alexa.Actions.Open\"],\"directive\":{\"name\":\"TurnOff\"}}]},"
"\"instance\":\"ESP-01.Toggle\","
"\"capabilityResources\":{\"friendlyNames\":["
"{\"@type\":\"text\",\"value\":{\"text\":\"Bedroom Blinds\",\"locale\":\"en-US\"}}]}}";
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_basic_light_discovery(void)
{
AlexaDevice device("Test Lamp", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::LIGHT);
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
assertDiscovery(endpoint("Test Lamp", "LIGHT", POWER), device);
}
void test_light_with_brightness_discovery(void)
{
AlexaDevice device("Test Lamp", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::LIGHT);
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
device.addCapability(AlexaInterfaceType::BRIGHTNESS_CONTROLLER);
assertDiscovery(endpoint("Test Lamp", "LIGHT", std::string(POWER) + "," + BRIGHTNESS), device);
}
void test_light_with_color_temperature_discovery(void)
{
AlexaDevice device("Test Lamp ESP", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::LIGHT);
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
device.addCapability(AlexaInterfaceType::BRIGHTNESS_CONTROLLER);
device.addCapability(AlexaInterfaceType::COLOR_TEMPERATURE_CONTROLLER);
assertDiscovery(endpoint("Test Lamp ESP", "LIGHT", std::string(POWER) + "," + BRIGHTNESS + "," + COLOR_TEMPERATURE),
device);
}
void test_temperature_sensor_discovery(void)
{
AlexaDevice device("ESP Temp", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::TEMPERATURE_SENSOR);
device.addCapability(AlexaInterfaceType::TEMPERATURE_SENSOR);
assertDiscovery(endpoint("ESP Temp", "TEMPERATURE_SENSOR", TEMPERATURE), device);
}
void test_blind_control_discovery(void)
{
String deviceName = "Bedroom Blinds";
AlexaDevice device(deviceName, "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::INTERIOR_BLIND);
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
AlexaInterface *toggleController = device.addCapability(AlexaInterfaceType::TOGGLE_CONTROLLER);
toggleController->setInstance("ESP-01.Toggle");
toggleController->addFriendlyName(deviceName.c_str(), "en-US");
ActionMapping closeMapping({AlexaActions::Close}, "TurnOn");
ActionMapping openMapping({AlexaActions::Open}, "TurnOff");
toggleController->addActionMapping(closeMapping);
toggleController->addActionMapping(openMapping);
assertDiscovery(endpoint("Bedroom Blinds", "INTERIOR_BLIND", std::string(POWER) + "," + BLIND_TOGGLE), device);
}
// What the library prints, for the tests that expect an error
class CapturedLog : public Print
{
public:
std::string text;
size_t write(const uint8_t *buffer, size_t size) override
{
text.append(reinterpret_cast<const char *>(buffer), size);
return size;
}
};
struct ExpectedRow
{
const AlexaInterfaceDesc *row;
AlexaInterfaceType type;
const char *ns;
const char *version;
const char *properties; // separated by commas
};
// The interface pages of the Alexa documentation, as the design lists them
static const ExpectedRow EXPECTED_ROWS[] = {
{&AlexaInterfaces::EndpointHealth, AlexaInterfaceType::ENDPOINT_HEALTH, "Alexa.EndpointHealth", "3.1", "connectivity"},
{&AlexaInterfaces::PowerController, AlexaInterfaceType::POWER_CONTROLLER, "Alexa.PowerController", "3", "powerState"},
{&AlexaInterfaces::BrightnessController, AlexaInterfaceType::BRIGHTNESS_CONTROLLER, "Alexa.BrightnessController", "3", "brightness"},
{&AlexaInterfaces::ColorController, AlexaInterfaceType::COLOR_CONTROLLER, "Alexa.ColorController", "3", "color"},
{&AlexaInterfaces::ColorTemperatureController, AlexaInterfaceType::COLOR_TEMPERATURE_CONTROLLER, "Alexa.ColorTemperatureController", "3", "colorTemperatureInKelvin"},
{&AlexaInterfaces::PowerLevelController, AlexaInterfaceType::POWER_LEVEL_CONTROLLER, "Alexa.PowerLevelController", "3", "powerLevel"},
{&AlexaInterfaces::PercentageController, AlexaInterfaceType::PERCENTAGE_CONTROLLER, "Alexa.PercentageController", "3", "percentage"},
{&AlexaInterfaces::RangeController, AlexaInterfaceType::RANGE_CONTROLLER, "Alexa.RangeController", "3", "rangeValue"},
{&AlexaInterfaces::ModeController, AlexaInterfaceType::MODE_CONTROLLER, "Alexa.ModeController", "3", "mode"},
{&AlexaInterfaces::ToggleController, AlexaInterfaceType::TOGGLE_CONTROLLER, "Alexa.ToggleController", "3", "toggleState"},
{&AlexaInterfaces::ThermostatController, AlexaInterfaceType::THERMOSTAT_CONTROLLER, "Alexa.ThermostatController", "3.2", "targetSetpoint,lowerSetpoint,upperSetpoint,thermostatMode"},
{&AlexaInterfaces::TemperatureSensor, AlexaInterfaceType::TEMPERATURE_SENSOR, "Alexa.TemperatureSensor", "3", "temperature"},
{&AlexaInterfaces::HumiditySensor, AlexaInterfaceType::HUMIDITY_SENSOR, "Alexa.HumiditySensor", "3", "relativeHumidity"},
{&AlexaInterfaces::LockController, AlexaInterfaceType::LOCK_CONTROLLER, "Alexa.LockController", "3", "lockState"},
{&AlexaInterfaces::SceneController, AlexaInterfaceType::SCENE_CONTROLLER, "Alexa.SceneController", "3", ""},
{&AlexaInterfaces::ContactSensor, AlexaInterfaceType::CONTACT_SENSOR, "Alexa.ContactSensor", "3", "detectionState"},
{&AlexaInterfaces::MotionSensor, AlexaInterfaceType::MOTION_SENSOR, "Alexa.MotionSensor", "3", "detectionState"},
{&AlexaInterfaces::DoorbellEventSource, AlexaInterfaceType::DOORBELL_EVENT_SOURCE, "Alexa.DoorbellEventSource", "3", ""},
{&AlexaInterfaces::TimeHoldController, AlexaInterfaceType::TIME_HOLD_CONTROLLER, "Alexa.TimeHoldController", "3", "holdStartTime,holdEndTime"},
{&AlexaInterfaces::Speaker, AlexaInterfaceType::SPEAKER, "Alexa.Speaker", "3", "volume,muted"},
{&AlexaInterfaces::StepSpeaker, AlexaInterfaceType::STEP_SPEAKER, "Alexa.StepSpeaker", "3", ""},
{&AlexaInterfaces::PlaybackController, AlexaInterfaceType::PLAYBACK_CONTROLLER, "Alexa.PlaybackController", "3", ""},
{&AlexaInterfaces::PlaybackStateReporter, AlexaInterfaceType::PLAYBACK_STATE_REPORTER, "Alexa.PlaybackStateReporter", "3", "playbackState"},
{&AlexaInterfaces::InputController, AlexaInterfaceType::INPUT_CONTROLLER, "Alexa.InputController", "3", "input"},
{&AlexaInterfaces::ChannelController, AlexaInterfaceType::CHANNEL_CONTROLLER, "Alexa.ChannelController", "3", "channel"},
{&AlexaInterfaces::InventoryLevelSensor, AlexaInterfaceType::INVENTORY_LEVEL_SENSOR, "Alexa.InventoryLevelSensor", "3", "level"},
{&AlexaInterfaces::SimpleEventSource, AlexaInterfaceType::SIMPLE_EVENT_SOURCE, "Alexa.SimpleEventSource", "1.0", ""},
{&AlexaInterfaces::WakeOnLANController, AlexaInterfaceType::WAKE_ON_LAN_CONTROLLER, "Alexa.WakeOnLANController", "3", ""},
};
void test_rows_hold_the_namespace_version_and_properties_of_their_interface(void)
{
TEST_ASSERT_EQUAL_size_t(28, sizeof(EXPECTED_ROWS) / sizeof(EXPECTED_ROWS[0]));
for (const ExpectedRow &expected : EXPECTED_ROWS)
{
const AlexaInterfaceDesc &row = *expected.row;
TEST_ASSERT_EQUAL_STRING(expected.ns, row.ns);
TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.version, row.version, expected.ns);
TEST_ASSERT_TRUE_MESSAGE(expected.type == row.interfaceType(), expected.ns);
std::string properties;
for (uint8_t i = 0; i < row.propertyCount(); i++)
{
properties += (i == 0 ? "" : ",");
properties += row.property(i);
}
TEST_ASSERT_EQUAL_STRING_MESSAGE(expected.properties, properties.c_str(), expected.ns);
}
}
void test_every_row_is_found_by_its_type(void)
{
for (const ExpectedRow &expected : EXPECTED_ROWS)
{
TEST_ASSERT_EQUAL_PTR_MESSAGE(expected.row, alexaInterfaceRow(expected.type), expected.ns);
}
TEST_ASSERT_NULL(alexaInterfaceRow(AlexaInterfaceType::COOKING));
TEST_ASSERT_NULL(alexaInterfaceRow(AlexaInterfaceType::UNKNOWN));
}
void test_generic_controllers_and_interfaces_without_properties_are_flagged(void)
{
TEST_ASSERT_TRUE(AlexaInterfaces::ToggleController.has(AIF_INSTANCED));
TEST_ASSERT_TRUE(AlexaInterfaces::RangeController.has(AIF_SEMANTICS));
TEST_ASSERT_TRUE(AlexaInterfaces::InventoryLevelSensor.has(AIF_INSTANCED));
TEST_ASSERT_FALSE(AlexaInterfaces::InventoryLevelSensor.has(AIF_SEMANTICS));
TEST_ASSERT_TRUE(AlexaInterfaces::SimpleEventSource.has(AIF_NO_PROPERTIES));
TEST_ASSERT_FALSE(AlexaInterfaces::PowerController.has(AIF_INSTANCED));
TEST_ASSERT_FALSE(AlexaInterfaces::PlaybackController.has(AIF_NO_PROPERTIES));
}
void test_capability_by_row_and_by_type_is_the_same_capability(void)
{
AlexaDevice device("Test Lamp", "root", "ESP-01");
AlexaInterface *byType = device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
AlexaInterface *byRow = device.addCapability(AlexaInterfaces::PowerController);
TEST_ASSERT_NOT_NULL(byType);
TEST_ASSERT_EQUAL_PTR(byType, byRow);
assertDiscovery(endpoint("Test Lamp", "OTHER", POWER), device);
}
void test_type_without_a_row_is_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
AlexaDevice device("Oven", "root", "ESP-01");
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaceType::COOKING));
TEST_ASSERT_NOT_NULL(strstr(log.text.c_str(), "[Alex2ESP] error: ESP-01: capability not added"));
assertDiscovery(endpoint("Oven", "OTHER", ""), device);
}
void test_scene_is_announced_without_properties(void)
{
AlexaDevice device("Movie Night", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::SCENE_TRIGGER);
device.addCapability(AlexaInterfaces::SceneController);
assertDiscovery(endpoint("Movie Night", "SCENE_TRIGGER",
"{\"interface\":\"Alexa.SceneController\",\"version\":\"3\",\"type\":\"AlexaInterface\"}"),
device);
}
void test_thermostat_announces_its_four_properties(void)
{
AlexaDevice device("Hallway", "root", "ESP-01");
device.setDisplayCategory(DisplayCategory::THERMOSTAT);
device.addCapability(AlexaInterfaceType::THERMOSTAT_CONTROLLER);
assertDiscovery(endpoint("Hallway", "THERMOSTAT",
"{\"interface\":\"Alexa.ThermostatController\",\"version\":\"3.2\",\"type\":\"AlexaInterface\","
"\"properties\":{\"retrievable\":true,\"proactivelyReported\":false,\"supported\":["
"{\"name\":\"targetSetpoint\"},{\"name\":\"lowerSetpoint\"},{\"name\":\"upperSetpoint\"},"
"{\"name\":\"thermostatMode\"}]}}"),
device);
}
void test_directive_gets_the_type_of_the_capability_it_names(void)
{
AlexaDevice device("Test Lamp", "root", "ESP-01");
device.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
device.addCapability(AlexaInterfaceType::BRIGHTNESS_CONTROLLER);
TEST_ASSERT_TRUE(AlexaInterfaceType::BRIGHTNESS_CONTROLLER == device.getInterfaceType("Alexa.BrightnessController"));
TEST_ASSERT_TRUE(AlexaInterfaceType::POWER_CONTROLLER == device.getInterfaceType("Alexa.PowerController"));
// An interface of the library that this device does not have, and no interface at all
TEST_ASSERT_TRUE(AlexaInterfaceType::UNKNOWN == device.getInterfaceType("Alexa.LockController"));
TEST_ASSERT_TRUE(AlexaInterfaceType::UNKNOWN == device.getInterfaceType("Alexa"));
}
void test_display_categories_have_the_name_of_their_enumerator(void)
{
TEST_ASSERT_EQUAL_STRING("ACTIVITY_TRIGGER", alexaDisplayCategoryName(DisplayCategory::ACTIVITY_TRIGGER));
TEST_ASSERT_EQUAL_STRING("OTHER", alexaDisplayCategoryName(DisplayCategory::OTHER));
TEST_ASSERT_EQUAL_STRING("WEARABLE", alexaDisplayCategoryName(DisplayCategory::WEARABLE));
TEST_ASSERT_EQUAL_STRING("TV", DisplayCategoryUtils::toString(DisplayCategory::TV).c_str());
TEST_ASSERT_EQUAL_STRING("OTHER", alexaDisplayCategoryName(static_cast<DisplayCategory>(200)));
}
int main(int, char **)
{
UNITY_BEGIN();
RUN_TEST(test_basic_light_discovery);
RUN_TEST(test_light_with_brightness_discovery);
RUN_TEST(test_light_with_color_temperature_discovery);
RUN_TEST(test_temperature_sensor_discovery);
RUN_TEST(test_blind_control_discovery);
RUN_TEST(test_rows_hold_the_namespace_version_and_properties_of_their_interface);
RUN_TEST(test_every_row_is_found_by_its_type);
RUN_TEST(test_generic_controllers_and_interfaces_without_properties_are_flagged);
RUN_TEST(test_capability_by_row_and_by_type_is_the_same_capability);
RUN_TEST(test_type_without_a_row_is_refused_with_an_error);
RUN_TEST(test_scene_is_announced_without_properties);
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);
return UNITY_END();
}