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

436
src/AlexaCapability.cpp Normal file
View file

@ -0,0 +1,436 @@
#include "AlexaCapability.h"
#include <stdlib.h>
#include <string.h>
#include "AlexaLog.h"
// The names are looked up by a value that is only known when the sketch runs, so both tables are always linked
static const char ACTION_OPEN[] PROGMEM = "Alexa.Actions.Open";
static const char ACTION_CLOSE[] PROGMEM = "Alexa.Actions.Close";
static const char ACTION_RAISE[] PROGMEM = "Alexa.Actions.Raise";
static const char ACTION_LOWER[] PROGMEM = "Alexa.Actions.Lower";
static const char ACTION_SET_ECO_ON[] PROGMEM = "Alexa.Actions.SetEcoOn";
static const char ACTION_SET_ECO_OFF[] PROGMEM = "Alexa.Actions.SetEcoOff";
static const char *const ACTION_NAMES[] PROGMEM = {ACTION_OPEN, ACTION_CLOSE, ACTION_RAISE,
ACTION_LOWER, ACTION_SET_ECO_ON, ACTION_SET_ECO_OFF};
static const uint8_t ACTION_COUNT = sizeof(ACTION_NAMES) / sizeof(ACTION_NAMES[0]);
static const char STATE_OPEN[] PROGMEM = "Alexa.States.Open";
static const char STATE_CLOSED[] PROGMEM = "Alexa.States.Closed";
static const char *const STATE_NAMES[] PROGMEM = {STATE_OPEN, STATE_CLOSED};
static const uint8_t STATE_COUNT = sizeof(STATE_NAMES) / sizeof(STATE_NAMES[0]);
static PGM_P nameAt(const char *const *names, uint8_t place)
{
return static_cast<PGM_P>(pgm_read_ptr(&names[place]));
}
PGM_P alexaActionName(AlexaAction action)
{
const uint8_t place = static_cast<uint8_t>(action);
return place < ACTION_COUNT ? nameAt(ACTION_NAMES, place) : PSTR("Unknown");
}
PGM_P alexaStateName(AlexaState state)
{
const uint8_t place = static_cast<uint8_t>(state);
return place < STATE_COUNT ? nameAt(STATE_NAMES, place) : PSTR("Unknown");
}
// The names of the bits that are set, in the order of the table
static void addNames(JsonArray target, uint8_t bits, const char *const *names, uint8_t count)
{
for (uint8_t place = 0; place < count; place++)
{
if ((bits & (1u << place)) != 0)
{
target.add(FPSTR(nameAt(names, place)));
}
}
}
// A copy on the heap; nullptr for no text, an empty text and a heap without room
static char *copyText(const char *text)
{
if (text == nullptr || text[0] == '\0')
{
return nullptr;
}
const size_t size = strlen(text) + 1;
char *copy = static_cast<char *>(malloc(size));
if (copy != nullptr)
{
memcpy(copy, text, size);
}
return copy;
}
AlexaCapabilityName::AlexaCapabilityName(const AlexaInterfaceDesc &row, const char *instance)
{
strncpy_P(text, row.ns, sizeof(text) - 1);
text[sizeof(text) - 1] = '\0';
if (instance != nullptr && instance[0] != '\0')
{
strncat(text, " ", sizeof(text) - strlen(text) - 1);
strncat(text, instance, sizeof(text) - strlen(text) - 1);
}
}
AlexaCapability::AlexaCapability(const AlexaInterfaceDesc &row, const char *instance)
: row(&row), retrievable(true), proactivelyReported(false), nonControllable(false)
{
setInstance(instance);
}
AlexaCapability::~AlexaCapability()
{
free(instance);
for (uint8_t i = 0; i < nameCount; i++)
{
if (names[i].locale != nullptr)
{
free(const_cast<char *>(names[i].value));
}
}
}
AlexaCapability &AlexaCapability::setInstance(const char *newInstance)
{
free(instance);
instance = copyText(newInstance);
if (instance == nullptr && newInstance != nullptr && newInstance[0] != '\0')
{
ALEX2ESP_LOGE("%s: instance not set: the heap has no room for it",
AlexaCapabilityName(*row, nullptr).text);
}
return *this;
}
AlexaCapability &AlexaCapability::addFriendlyName(const char *text, const char *locale)
{
const AlexaCapabilityName name(*row, instance);
if (text == nullptr || text[0] == '\0' || locale == nullptr || locale[0] == '\0')
{
ALEX2ESP_LOGE("%s: friendly name not added: pass a text and a locale, addFriendlyName(\"Lift\", \"en-US\")",
name.text);
return *this;
}
if (nameCount >= ALEX2ESP_MAX_FRIENDLY_NAMES)
{
ALEX2ESP_LOGE("%s: friendly name \"%s\" not added: it has %d already (ALEX2ESP_MAX_FRIENDLY_NAMES)",
name.text, text, ALEX2ESP_MAX_FRIENDLY_NAMES);
return *this;
}
const char *copy = copyText(text);
if (copy == nullptr)
{
ALEX2ESP_LOGE("%s: friendly name \"%s\" not added: the heap has no room for it", name.text, text);
return *this;
}
names[nameCount++] = {copy, locale};
return *this;
}
AlexaCapability &AlexaCapability::addFriendlyAsset(PGM_P assetId)
{
const AlexaCapabilityName name(*row, instance);
if (assetId == nullptr)
{
ALEX2ESP_LOGE("%s: asset not added: pass its id, addFriendlyAsset(PSTR(\"Alexa.Setting.Opening\"))", name.text);
return *this;
}
if (nameCount >= ALEX2ESP_MAX_FRIENDLY_NAMES)
{
ALEX2ESP_LOGE("%s: asset not added: it has %d names already (ALEX2ESP_MAX_FRIENDLY_NAMES)",
name.text, ALEX2ESP_MAX_FRIENDLY_NAMES);
return *this;
}
names[nameCount++] = {assetId, nullptr};
return *this;
}
AlexaCapability &AlexaCapability::setRetrievable(bool value)
{
retrievable = value;
return *this;
}
AlexaCapability &AlexaCapability::setProactivelyReported(bool value)
{
proactivelyReported = value;
return *this;
}
AlexaCapability &AlexaCapability::setNonControllable(bool value)
{
nonControllable = value;
return *this;
}
AlexaCapability &AlexaCapability::setConfiguration(AlexaConfigurationFiller fill, void *context)
{
fillConfiguration = fill;
configurationContext = context;
return *this;
}
AlexaCapability &AlexaCapability::addActionMapping(const ActionMapping &mapping)
{
const AlexaCapabilityName name(*row, instance);
if (!row->has(AIF_SEMANTICS))
{
ALEX2ESP_LOGE("%s: action mapping not added: only RangeController, ModeController and ToggleController take one",
name.text);
return *this;
}
if (mapping.actions == 0 || mapping.directiveName == nullptr || mapping.directiveName[0] == '\0')
{
ALEX2ESP_LOGE("%s: action mapping not added: it needs an action and a directive, ActionMapping({AlexaAction::Open}, \"TurnOn\")",
name.text);
return *this;
}
if (actionCount >= ALEX2ESP_MAX_ACTION_MAPPINGS)
{
ALEX2ESP_LOGE("%s: action mapping to %s not added: it has %d already (ALEX2ESP_MAX_ACTION_MAPPINGS)",
name.text, mapping.directiveName, ALEX2ESP_MAX_ACTION_MAPPINGS);
return *this;
}
actionMappings[actionCount++] = mapping;
return *this;
}
AlexaCapability::StateMapping *AlexaCapability::newStateMapping(std::initializer_list<AlexaState> states,
StateMappingKind kind)
{
const AlexaCapabilityName name(*row, instance);
if (!row->has(AIF_SEMANTICS))
{
ALEX2ESP_LOGE("%s: state mapping not added: only RangeController, ModeController and ToggleController take one",
name.text);
return nullptr;
}
if (states.size() == 0)
{
ALEX2ESP_LOGE("%s: state mapping not added: it needs a state, addStateMapping({AlexaState::Closed}, 0.0f)",
name.text);
return nullptr;
}
if (stateCount >= ALEX2ESP_MAX_STATE_MAPPINGS)
{
ALEX2ESP_LOGE("%s: state mapping not added: it has %d already (ALEX2ESP_MAX_STATE_MAPPINGS)",
name.text, ALEX2ESP_MAX_STATE_MAPPINGS);
return nullptr;
}
StateMapping *mapping = &stateMappings[stateCount++];
mapping->states = 0;
for (AlexaState state : states)
{
mapping->states |= static_cast<uint8_t>(1u << static_cast<uint8_t>(state));
}
mapping->kind = kind;
mapping->maximum = 0;
return mapping;
}
AlexaCapability &AlexaCapability::addStateMapping(std::initializer_list<AlexaState> states, float value)
{
StateMapping *mapping = newStateMapping(states, STATES_TO_VALUE);
if (mapping != nullptr)
{
mapping->value = value;
}
return *this;
}
AlexaCapability &AlexaCapability::addStateMapping(std::initializer_list<AlexaState> states, float minimum, float maximum)
{
StateMapping *mapping = newStateMapping(states, STATES_TO_RANGE);
if (mapping != nullptr)
{
mapping->value = minimum;
mapping->maximum = maximum;
}
return *this;
}
AlexaCapability &AlexaCapability::addStateMapping(std::initializer_list<AlexaState> states, const char *value)
{
if (value == nullptr)
{
ALEX2ESP_LOGE("%s: state mapping not added: it needs a value, addStateMapping({AlexaState::Closed}, \"OFF\")",
AlexaCapabilityName(*row, instance).text);
return *this;
}
StateMapping *mapping = newStateMapping(states, STATES_TO_TEXT);
if (mapping != nullptr)
{
mapping->text = value;
}
return *this;
}
bool AlexaCapability::matches(const char *ns, const char *directiveInstance) const
{
if (ns == nullptr || strcmp_P(ns, row->ns) != 0)
{
return false;
}
if (!row->has(AIF_INSTANCED))
{
return true;
}
return directiveInstance != nullptr && instance != nullptr && strcmp(directiveInstance, instance) == 0;
}
// The keys that only some capabilities have are F() strings: they stay in flash and are copied into the document
// of a capability that has them. A plain literal takes static RAM in every sketch.
bool AlexaCapability::toJson(JsonArray capabilities, const char *endpointId) const
{
if (row->has(AIF_INSTANCED) && (instance == nullptr || nameCount == 0))
{
const AlexaCapabilityName name(*row, instance);
if (instance == nullptr)
{
ALEX2ESP_LOGE("%s: %s left out of discovery: it has no instance, call setInstance(\"Blind.Lift\") on it",
endpointId, name.text);
}
else
{
ALEX2ESP_LOGE("%s: %s left out of discovery: it has no name, call addFriendlyName(\"Lift\", \"en-US\") on it",
endpointId, name.text);
}
return false;
}
JsonObject capability = capabilities.add<JsonObject>();
capability["interface"] = FPSTR(row->ns);
capability["version"] = FPSTR(row->version);
capability["type"] = "AlexaInterface";
if (row->has(AIF_EMPTY_PROPERTIES))
{
capability["properties"].to<JsonObject>();
}
else if (!row->has(AIF_NO_PROPERTIES))
{
JsonObject properties = capability["properties"].to<JsonObject>();
properties["retrievable"] = static_cast<bool>(retrievable);
properties["proactivelyReported"] = static_cast<bool>(proactivelyReported);
JsonArray supported = properties["supported"].to<JsonArray>();
for (uint8_t i = 0; i < row->propertyCount(); i++)
{
supported.add<JsonObject>()["name"] = FPSTR(row->property(i));
}
if (nonControllable)
{
properties[F("nonControllable")] = true;
}
}
writeSemantics(capability);
if (instance != nullptr)
{
capability["instance"] = static_cast<const char *>(instance);
}
writeNames(capability);
if (fillConfiguration != nullptr)
{
fillConfiguration(capability[F("configuration")].to<JsonObject>(), configurationContext);
}
return true;
}
// Semantics exist only when there is something to map, as alex2node announces them
void AlexaCapability::writeSemantics(JsonObject capability) const
{
if (actionCount == 0 && stateCount == 0)
{
return;
}
JsonObject semantics = capability["semantics"].to<JsonObject>();
if (actionCount > 0)
{
JsonArray mappings = semantics["actionMappings"].to<JsonArray>();
for (uint8_t i = 0; i < actionCount; i++)
{
const ActionMapping &action = actionMappings[i];
JsonObject mapping = mappings.add<JsonObject>();
mapping["@type"] = "ActionsToDirective";
addNames(mapping["actions"].to<JsonArray>(), action.actions, ACTION_NAMES, ACTION_COUNT);
JsonObject directive = mapping["directive"].to<JsonObject>();
directive["name"] = action.directiveName;
if (action.directivePayload == nullptr || action.directivePayload[0] == '\0')
{
continue;
}
// Alexa expects the payload as an object, not as a string that holds JSON. Parsed with its length,
// as the bridge parses a directive: the sketch links one parser and not two.
JsonDocument payload;
const DeserializationError error =
deserializeJson(payload, action.directivePayload, strlen(action.directivePayload));
if (error == DeserializationError::Ok && payload.is<JsonObject>())
{
directive["payload"] = payload.as<JsonObject>();
}
else
{
ALEX2ESP_LOGE("%s: payload of the action mapping to %s left out: it is not the JSON text of an object",
AlexaCapabilityName(*row, instance).text, action.directiveName);
}
}
}
if (stateCount > 0)
{
JsonArray mappings = semantics[F("stateMappings")].to<JsonArray>();
for (uint8_t i = 0; i < stateCount; i++)
{
const StateMapping &state = stateMappings[i];
JsonObject mapping = mappings.add<JsonObject>();
mapping["@type"] = state.kind == STATES_TO_RANGE ? F("StatesToRange") : F("StatesToValue");
addNames(mapping[F("states")].to<JsonArray>(), state.states, STATE_NAMES, STATE_COUNT);
if (state.kind == STATES_TO_RANGE)
{
JsonObject range = mapping[F("range")].to<JsonObject>();
range[F("minimumValue")] = state.value;
range[F("maximumValue")] = state.maximum;
}
else if (state.kind == STATES_TO_TEXT)
{
mapping["value"] = state.text;
}
else
{
mapping["value"] = state.value;
}
}
}
}
void AlexaCapability::writeNames(JsonObject capability) const
{
if (nameCount == 0)
{
return;
}
JsonArray friendlyNames = capability["capabilityResources"]["friendlyNames"].to<JsonArray>();
for (uint8_t i = 0; i < nameCount; i++)
{
JsonObject friendlyName = friendlyNames.add<JsonObject>();
if (names[i].locale == nullptr)
{
friendlyName["@type"] = F("asset");
friendlyName["value"][F("assetId")] = FPSTR(names[i].value);
}
else
{
friendlyName["@type"] = "text";
JsonObject value = friendlyName["value"].to<JsonObject>();
value["text"] = names[i].value;
value["locale"] = names[i].locale;
}
}
}

181
src/AlexaCapability.h Normal file
View file

@ -0,0 +1,181 @@
// One capability of one device: a row of AlexaInterfaces and what this device adds to it. For most interfaces that
// is nothing. A generic controller (RangeController, ModeController, ToggleController) has an instance and the
// names Alexa calls it by, a configuration, and the words Alexa maps to its directives and states.
// alexa-discovery-objects.html, generic-controllers.html under
// https://developer.amazon.com/en-US/docs/alexa/device-apis/
#ifndef ALEXA_CAPABILITY_H
#define ALEXA_CAPABILITY_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <initializer_list>
#include "AlexaInterfaces.h"
#include "AlexaLimits.h"
// What a user says: "open", "close", "raise", "lower" (alexa-discovery-objects.html#action-mapping)
enum class AlexaAction : uint8_t
{
Open,
Close,
Raise,
Lower,
SetEcoOn,
SetEcoOff
};
// What Alexa says about the device: "the blind is open" (alexa-discovery-objects.html#state-mapping)
enum class AlexaState : uint8_t
{
Open,
Closed
};
// "Alexa.Actions.Open", in program memory
PGM_P alexaActionName(AlexaAction action);
// "Alexa.States.Open", in program memory
PGM_P alexaStateName(AlexaState state);
// The directive Alexa sends for one or more actions. The name of the directive and its payload, the JSON text of
// an object, are kept as pointers and not copied: pass literals. Actions are announced in the order of AlexaAction.
class ActionMapping
{
public:
ActionMapping() {}
ActionMapping(std::initializer_list<AlexaAction> actions, const char *directiveName,
const char *directivePayload = nullptr)
: directiveName(directiveName), directivePayload(directivePayload)
{
for (AlexaAction action : actions)
{
this->actions |= static_cast<uint8_t>(1u << static_cast<uint8_t>(action));
}
}
uint8_t actions = 0; // one bit per AlexaAction
const char *directiveName = nullptr;
const char *directivePayload = nullptr; // nullptr or "" for a directive without payload
};
// Writes the "configuration" object of a capability when the device is announced; context is what the sketch
// passed to setConfiguration()
typedef void (*AlexaConfigurationFiller)(JsonObject configuration, void *context);
// "Alexa.RangeController Blind.Lift" for a log line. The namespace is in program memory, where the %s of the log
// cannot read it.
struct AlexaCapabilityName
{
AlexaCapabilityName(const AlexaInterfaceDesc &row, const char *instance);
char text[72];
};
// 116 bytes on the heap of an ESP8266, whatever the capability holds. Every setter returns the capability:
// device->addCapability(AlexaInterfaces::RangeController, "Blind.Lift")
// ->addFriendlyName("Lift", "en-US").setConfiguration(fillLift).addStateMapping({AlexaState::Closed}, 0.0f);
// What a capability refuses is printed at ERROR with what to change, and leaves the capability as it was.
class AlexaCapability
{
public:
explicit AlexaCapability(const AlexaInterfaceDesc &row, const char *instance = nullptr);
~AlexaCapability();
// It owns copies of its instance and its names
AlexaCapability(const AlexaCapability &) = delete;
AlexaCapability &operator=(const AlexaCapability &) = delete;
const AlexaInterfaceDesc &getRow() const { return *row; }
AlexaInterfaceType getType() const { return row->interfaceType(); }
String getTypeString() const { return String(FPSTR(row->ns)); }
String getVersion() const { return String(FPSTR(row->version)); }
// "" when the capability has none
const char *getInstance() const { return instance != nullptr ? instance : ""; }
bool isRetrievable() const { return retrievable; }
bool isProactivelyReported() const { return proactivelyReported; }
bool isNonControllable() const { return nonControllable; }
// The instance tells the capabilities of one interface on a device apart ("Blind.Lift", "Blind.Tilt"). It is
// copied. addCapability(row, instance) sets it; this is for a capability that was added by its type.
AlexaCapability &setInstance(const char *instance);
// A name of the capability in words. The text is copied, the locale ("en-US") is not: pass a literal.
AlexaCapability &addFriendlyName(const char *text, const char *locale);
// A name from the catalog of Alexa, which Alexa translates: PSTR("Alexa.Setting.Opening"). Not copied.
AlexaCapability &addFriendlyAsset(PGM_P assetId);
AlexaCapability &setRetrievable(bool value);
AlexaCapability &setProactivelyReported(bool value);
// true: Alexa reports the state and sends no directive that changes it
AlexaCapability &setNonControllable(bool value);
AlexaCapability &setConfiguration(AlexaConfigurationFiller fill, void *context = nullptr);
// Only for an interface that takes semantics: RangeController, ModeController, ToggleController
AlexaCapability &addActionMapping(const ActionMapping &mapping);
// The states a value stands for: a number or a range of a RangeController, the text of a mode or "ON"/"OFF"
// of a ToggleController. A text is not copied: pass a literal.
AlexaCapability &addStateMapping(std::initializer_list<AlexaState> states, float value);
AlexaCapability &addStateMapping(std::initializer_list<AlexaState> states, float minimum, float maximum);
AlexaCapability &addStateMapping(std::initializer_list<AlexaState> states, const char *value);
// Whether a directive with this namespace and instance is for this capability. The instance counts only
// for an interface that has instances.
bool matches(const char *ns, const char *directiveInstance) const;
// Adds the discovery object of the capability to the capabilities of its endpoint. A capability that Alexa
// would reject the endpoint for, a generic controller without an instance or without a name, adds nothing,
// prints what is missing and returns false.
bool toJson(JsonArray capabilities, const char *endpointId) const;
private:
struct Name
{
const char *value; // the text, a copy on the heap, or the asset id in program memory
const char *locale; // nullptr for an asset
};
enum StateMappingKind : uint8_t
{
STATES_TO_VALUE,
STATES_TO_RANGE,
STATES_TO_TEXT
};
struct StateMapping
{
uint8_t states; // one bit per AlexaState
uint8_t kind; // StateMappingKind
union
{
float value; // the value, or the lowest of a range
const char *text;
};
float maximum;
};
StateMapping *newStateMapping(std::initializer_list<AlexaState> states, StateMappingKind kind);
void writeSemantics(JsonObject capability) const;
void writeNames(JsonObject capability) const;
const AlexaInterfaceDesc *row; // in program memory, never nullptr
char *instance = nullptr; // a copy on the heap
AlexaConfigurationFiller fillConfiguration = nullptr;
void *configurationContext = nullptr;
Name names[ALEX2ESP_MAX_FRIENDLY_NAMES];
ActionMapping actionMappings[ALEX2ESP_MAX_ACTION_MAPPINGS];
StateMapping stateMappings[ALEX2ESP_MAX_STATE_MAPPINGS];
uint8_t nameCount = 0;
uint8_t actionCount = 0;
uint8_t stateCount = 0;
bool retrievable : 1;
bool proactivelyReported : 1;
bool nonControllable : 1;
};
#endif // ALEXA_CAPABILITY_H

View file

@ -72,28 +72,34 @@ String AlexaDevice::getSoftwareVersion() const {
}
AlexaInterface* AlexaDevice::addCapability(const AlexaInterfaceDesc& row) {
// Check if a interface with the given row already exists
for (auto& iface : capabilities) {
if (&iface.getRow() == &row) {
return &iface; // Return the existing interface
AlexaCapability* AlexaDevice::addCapability(const AlexaInterfaceDesc& row, const char* instance) {
const bool hasInstance = instance != nullptr && instance[0] != '\0';
if (row.has(AIF_INSTANCED) && !hasInstance) {
ALEX2ESP_LOGE("%s: capability %s not added: it needs an instance, addCapability(row, \"Blind.Lift\")",
endpointId.c_str(), AlexaCapabilityName(row, nullptr).text);
return nullptr;
}
if (!row.has(AIF_INSTANCED) && hasInstance) {
ALEX2ESP_LOGE("%s: capability %s not added: the interface takes no instance, leave it out",
endpointId.c_str(), AlexaCapabilityName(row, instance).text);
return nullptr;
}
return capabilityOf(row, instance);
}
AlexaCapability* AlexaDevice::capabilityOf(const AlexaInterfaceDesc& row, const char* instance) {
for (AlexaCapability& capability : capabilities) {
if (&capability.getRow() == &row && (instance == nullptr || strcmp(capability.getInstance(), instance) == 0)) {
return &capability;
}
}
// If the interface doesn't exist, create a new one
capabilities.emplace_back(row);
// The namespace is in program memory, where the %s of the log cannot read it
char interfaceName[40];
strncpy_P(interfaceName, row.ns, sizeof(interfaceName) - 1);
interfaceName[sizeof(interfaceName) - 1] = '\0';
ALEX2ESP_LOGI("%s: capability %s added", endpointId.c_str(), interfaceName);
// Return a pointer to the newly created device
capabilities.emplace_back(row, instance);
ALEX2ESP_LOGI("%s: capability %s added", endpointId.c_str(), AlexaCapabilityName(row, instance).text);
return &capabilities.back();
}
AlexaInterface* AlexaDevice::refuseCapability(AlexaInterfaceType type) {
AlexaCapability* AlexaDevice::refuseCapability(AlexaInterfaceType type) {
ALEX2ESP_LOGE("%s: capability not added: the library has no description of interface type %d; "
"readme.md lists the interfaces it has (Interface Types)",
endpointId.c_str(), static_cast<int>(type));
@ -101,7 +107,7 @@ AlexaInterface* AlexaDevice::refuseCapability(AlexaInterfaceType type) {
}
AlexaInterfaceType AlexaDevice::getInterfaceType(const char* interfaceName) const {
for (const AlexaInterface& capability : capabilities) {
for (const AlexaCapability& capability : capabilities) {
if (strcmp_P(interfaceName, capability.getRow().ns) == 0) {
return capability.getType();
}
@ -136,31 +142,14 @@ JsonDocument AlexaDevice::getDeviceJSON() const {
JsonArray capabilitiesArray = json["capabilities"].to<JsonArray>();
for (const AlexaInterface& capability : capabilities) {
capabilitiesArray.add(capability.getJSON().as<JsonObject>());
// A capability that cannot be announced says so itself; the device is announced with the others
for (const AlexaCapability& capability : capabilities) {
capability.toJson(capabilitiesArray, endpointId.c_str());
}
// capabilitiesArray.add(capability.getJSON().as<JsonObject>());
return json;
}
// void AlexaDevice::registerEvent(const String& eventName, std::function<void(const JsonDocument&,const AlexaInterfaceType&)> callback) {
// eventCallbacks[eventName] = callback;
// }
// void AlexaDevice::triggerEvent(const String& eventName, const JsonDocument& directive,const AlexaInterfaceType& type) const {
// auto it = eventCallbacks.find(eventName);
// if (it != eventCallbacks.end()) {
// // Call the registered callback function
// it->second(directive,type);
// } else {
// Serial.print("No event registered for: ");
// Serial.println(eventName);
// }
// }
// Register an event callback function
void AlexaDevice::registerEvent(const char* eventName, void (*callback)(const JsonDocument&, const AlexaInterfaceType&)) {
for (int i = 0; i < MAX_EVENTS; ++i) {

View file

@ -58,17 +58,20 @@ public:
JsonDocument getDeviceJSON() const;
// Returns the capability that describes this interface, creating it on first use. The pointer stays valid for
// the lifetime of the device: capabilities live in a std::deque, which never relocates its elements when
// another one is added.
AlexaInterface* addCapability(const AlexaInterfaceDesc& row);
// Returns the capability of this device for the interface, creating it on first use. An interface that a
// device may have several of (RangeController, ModeController, ToggleController) takes the instance that tells
// them apart, "Blind.Lift"; without one, and with an instance for any other interface, nothing is added, the
// reason is printed and nullptr returned. The pointer stays valid for the lifetime of the device:
// capabilities live in a std::deque, which never relocates its elements when another one is added.
AlexaCapability* addCapability(const AlexaInterfaceDesc& row, const char* instance = nullptr);
// The same by the type of the interface, as 1.x sketches write it. A type without a row in AlexaInterfaces
// cannot be described: nothing is added, the reason is printed and nullptr returned. Inlined, so that a type
// written out in the sketch links its row only (alexaInterfaceRow).
__attribute__((always_inline)) AlexaInterface* addCapability(AlexaInterfaceType type) {
// The same by the type of the interface, as 1.x sketches write it: they call setInstance() on what they get,
// so the instance is not asked for here but when the device is announced. A type without a row in
// AlexaInterfaces cannot be described: nothing is added, the reason is printed and nullptr returned. Inlined,
// so that a type written out in the sketch links its row only (alexaInterfaceRow).
__attribute__((always_inline)) AlexaCapability* addCapability(AlexaInterfaceType type) {
const AlexaInterfaceDesc* row = alexaInterfaceRow(type);
return row != nullptr ? addCapability(*row) : refuseCapability(type);
return row != nullptr ? capabilityOf(*row, nullptr) : refuseCapability(type);
}
// The type of the capability of this device with the namespace of a directive ("Alexa.PowerController"),
@ -86,7 +89,9 @@ public:
}
private:
AlexaInterface* refuseCapability(AlexaInterfaceType type);
// The capability with this row and, when one is given, this instance; added when the device has none
AlexaCapability* capabilityOf(const AlexaInterfaceDesc& row, const char* instance);
AlexaCapability* refuseCapability(AlexaInterfaceType type);
String name;
String endpointId;
@ -102,7 +107,7 @@ private:
String model = "Alexa2MQTT";
const String softwareVersion = ALEX2ESP_VERSION;
std::deque<AlexaInterface> capabilities; // deque: pointers handed out by addCapability stay valid
std::deque<AlexaCapability> capabilities; // deque: pointers handed out by addCapability stay valid
};

View file

@ -1,202 +1,17 @@
// The names of library 1.x for what 2.0 calls AlexaCapability and AlexaAction. They stay through 2.x, so that a
// sketch written for 1.x compiles as it is; new sketches use the 2.0 names.
#ifndef ALEXA_INTERFACE_H
#define ALEXA_INTERFACE_H
#include <string>
#include <vector>
#include <Arduino.h>
#include <ArduinoJson.h>
#include "AlexaInterfaces.h"
#include "AlexaLog.h"
enum class AlexaActions {
Open,
Close,
Raise,
Lower,
SetEcoOn,
SetEcoOff
};
#include "AlexaCapability.h"
typedef AlexaCapability AlexaInterface;
typedef AlexaAction AlexaActions;
class AlexaActionsUtils
{
public:
// Helper function to convert AlexaActions enum to string
static String toString(AlexaActions action) {
switch (action) {
case AlexaActions::Open: return "Alexa.Actions.Open";
case AlexaActions::Close: return "Alexa.Actions.Close";
case AlexaActions::Raise: return "Alexa.Actions.Raise";
case AlexaActions::Lower: return "Alexa.Actions.Lower";
case AlexaActions::SetEcoOn: return "Alexa.Actions.SetEcoOn";
case AlexaActions::SetEcoOff: return "Alexa.Actions.SetEcoOff";
default: return "Unknown";
}
}
};
class FriendlyName {
public:
FriendlyName(const std::string& t, const std::string& l)
: text(t), locale(l) {}
std::string text;
std::string locale;
};
// Updated ActionMapping class definition
class ActionMapping {
public:
String type = "ActionsToDirective"; // Type of action mapping (always "ActionsToDirective")
std::vector<AlexaActions> actions; // Array of AlexaActions
struct Directive {
String name; // Name of the directive
String payload; // JSON text of the directive's payload object, empty when there is none
} directive; // Directive object containing name and payload
// Constructor
ActionMapping(std::vector<AlexaActions> actions, String directiveName, String directivePayload="")
: actions(actions) {
directive.name = directiveName;
directive.payload = directivePayload;
}
// Get the JSON representation of ActionMapping
JsonDocument getJSON() const {
JsonDocument doc;
doc["@type"] = type;
// Convert actions array to strings
JsonArray actionArray = doc["actions"].to<JsonArray>();
for (const auto& action : actions) {
actionArray.add(AlexaActionsUtils::toString(action));
}
JsonObject directiveObj = doc["directive"].to<JsonObject>();
directiveObj["name"] = directive.name;
if (directive.payload.length() > 0) {
// Alexa expects the payload as an object, not as a string holding JSON: parse the text we were given
JsonDocument payloadDoc;
if (deserializeJson(payloadDoc, directive.payload) == DeserializationError::Ok && payloadDoc.is<JsonObject>()) {
directiveObj["payload"] = payloadDoc.as<JsonObject>();
} else {
ALEX2ESP_LOGE("action mapping payload is not a JSON object, omitted: %s", directive.payload.c_str());
}
}
return doc;
}
};
// A capability of a device: a row of AlexaInterfaces and what this device adds to it
class AlexaInterface
{
private:
const AlexaInterfaceDesc *row; // in program memory, never nullptr
bool retrievable;
bool proactivelyReported;
std::vector<ActionMapping> actionMappings; // Array of ActionMappings
std::vector<FriendlyName> friendlyNames; // Array of FriendlyNames
String instance;
bool nameSet = false;
public:
// Constructor
AlexaInterface(const AlexaInterfaceDesc &row, bool retrievable = true, bool proactivelyReported = false)
: row(&row), retrievable(retrievable), proactivelyReported(proactivelyReported) {}
// Getters
const AlexaInterfaceDesc &getRow() const { return *row; }
AlexaInterfaceType getType() const { return row->interfaceType(); }
String getTypeString() const { return String(FPSTR(row->ns)); }
String getVersion() const { return String(FPSTR(row->version)); }
std::vector<String> getProps() const
{
std::vector<String> props;
for (uint8_t i = 0; i < row->propertyCount(); i++)
{
props.push_back(String(FPSTR(row->property(i))));
}
return props;
}
void setInstance(const char* name){
instance=name;
nameSet=true;
}
void addFriendlyName(const char* name,const char* locale){
friendlyNames.emplace_back(name,locale);
}
bool isRetrievable() const { return retrievable; }
bool isProactivelyReported() const { return proactivelyReported; }
// Setters
void setRetrievable(bool value) { retrievable = value; }
void setProactivelyReported(bool value) { proactivelyReported = value; }
void addActionMapping(const ActionMapping& actionMapping) {
actionMappings.push_back(actionMapping);
}
JsonDocument getJSON() const
{
JsonDocument doc;
// Set base properties
doc["interface"] = FPSTR(row->ns);
doc["version"] = FPSTR(row->version);
doc["type"] = "AlexaInterface";
// Add properties, unless the interface has none to report (a scene, a doorbell)
if (!row->has(AIF_NO_PROPERTIES)) {
JsonObject properties = doc["properties"].to<JsonObject>();
properties["retrievable"] = retrievable;
properties["proactivelyReported"] = proactivelyReported;
JsonArray supported = properties["supported"].to<JsonArray>();
for (uint8_t i = 0; i < row->propertyCount(); i++)
{
supported.add<JsonObject>()["name"] = FPSTR(row->property(i));
}
}
// Add action mappings - semantics only exist when there is something to map (as alex2node does)
if (!actionMappings.empty()) {
JsonObject semantics = doc["semantics"].to<JsonObject>();
JsonArray actionMappingArray = semantics["actionMappings"].to<JsonArray>();
for (const ActionMapping& actionMapping : actionMappings) {
actionMappingArray.add(actionMapping.getJSON().as<JsonObject>());
}
}
if(nameSet){
doc["instance"]=instance;
}
// Add capabilityResources if friendlyNames is not empty
if (!friendlyNames.empty()) {
JsonObject capabilityResources = doc["capabilityResources"].to<JsonObject>();
JsonArray friendlyNamesArray = capabilityResources["friendlyNames"].to<JsonArray>();
for (const FriendlyName& fn : friendlyNames) {
JsonObject friendlyNameObj = friendlyNamesArray.add<JsonObject>();
friendlyNameObj["@type"] = "text";
JsonObject valueObj = friendlyNameObj["value"].to<JsonObject>();
valueObj["text"] = fn.text;
valueObj["locale"] = fn.locale;
}
}
doc.shrinkToFit();
return doc;
}
static String toString(AlexaActions action) { return String(FPSTR(alexaActionName(action))); }
};
#endif // ALEXA_INTERFACE_H

View file

@ -110,13 +110,13 @@ ALEXA_ROW(DoorbellEventSource, NS_DOORBELL, VERSION_3, nullptr, 0, AIF_NO_PROPER
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(StepSpeaker, NS_STEP_SPEAKER, VERSION_3, nullptr, 0, AIF_NO_PROPERTIES, STEP_SPEAKER);
ALEXA_ROW(PlaybackController, NS_PLAYBACK, VERSION_3, nullptr, 0, AIF_NONE, PLAYBACK_CONTROLLER);
ALEXA_ROW(PlaybackController, NS_PLAYBACK, VERSION_3, nullptr, 0, AIF_EMPTY_PROPERTIES, PLAYBACK_CONTROLLER);
ALEXA_ROW(PlaybackStateReporter, NS_PLAYBACK_STATE, VERSION_3, PROPS_PLAYBACK_STATE, 1, AIF_NONE, PLAYBACK_STATE_REPORTER);
ALEXA_ROW(InputController, NS_INPUT, VERSION_3, PROPS_INPUT, 1, AIF_NONE, INPUT_CONTROLLER);
ALEXA_ROW(ChannelController, NS_CHANNEL, VERSION_3, PROPS_CHANNEL, 1, AIF_NONE, CHANNEL_CONTROLLER);
ALEXA_ROW(InventoryLevelSensor, NS_INVENTORY_LEVEL, VERSION_3, PROPS_INVENTORY_LEVEL, 1, AIF_INSTANCED, INVENTORY_LEVEL_SENSOR);
ALEXA_ROW(SimpleEventSource, NS_SIMPLE_EVENT, VERSION_1_0, nullptr, 0, AIF_INSTANCED | AIF_NO_PROPERTIES, SIMPLE_EVENT_SOURCE);
ALEXA_ROW(WakeOnLANController, NS_WAKE_ON_LAN, VERSION_3, nullptr, 0, AIF_NONE, WAKE_ON_LAN_CONTROLLER);
ALEXA_ROW(WakeOnLANController, NS_WAKE_ON_LAN, VERSION_3, nullptr, 0, AIF_EMPTY_PROPERTIES, WAKE_ON_LAN_CONTROLLER);
// The display categories in the order of the enum. Their names are looked up by a value that is only known when
// the sketch runs, so this table is always linked (about 1 KB of flash).

View file

@ -88,9 +88,10 @@ enum class AlexaInterfaceType : uint8_t
enum AlexaInterfaceFlags : uint8_t
{
AIF_NONE = 0,
AIF_INSTANCED = 1, // a generic controller: needs an instance and friendly names
AIF_NO_PROPERTIES = 2, // the discovery object has no "properties" (scenes, doorbells, step speakers)
AIF_SEMANTICS = 4 // may carry action and state mappings
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_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
};
// 16 bytes in program memory. The ESP8266 reads flash in whole words only: the pointers can be read as they are,

View file

@ -32,6 +32,20 @@
#define ALEX2ESP_MAX_QUEUED_BYTES (4 * ALEX2ESP_MAX_DIRECTIVE)
#endif
// What one capability holds: the names Alexa calls it by, the actions and the states it maps. Each place takes 8
// or 12 bytes in every capability of the sketch, used or not. One more than fits is refused with an error.
#ifndef ALEX2ESP_MAX_FRIENDLY_NAMES
#define ALEX2ESP_MAX_FRIENDLY_NAMES 3
#endif
#ifndef ALEX2ESP_MAX_ACTION_MAPPINGS
#define ALEX2ESP_MAX_ACTION_MAPPINGS 4
#endif
#ifndef ALEX2ESP_MAX_STATE_MAPPINGS
#define ALEX2ESP_MAX_STATE_MAPPINGS 2
#endif
#if ALEX2ESP_MAX_QUEUED_DIRECTIVES < 1 || ALEX2ESP_MAX_QUEUED_DIRECTIVES > 64
#error "ALEX2ESP_MAX_QUEUED_DIRECTIVES has to be between 1 and 64"
#endif
@ -40,4 +54,9 @@
#error "ALEX2ESP_MAX_QUEUED_BYTES has to be ALEX2ESP_MAX_DIRECTIVE or more: the largest directive has to fit the empty queue"
#endif
#if ALEX2ESP_MAX_FRIENDLY_NAMES < 1 || ALEX2ESP_MAX_FRIENDLY_NAMES > 255 || ALEX2ESP_MAX_ACTION_MAPPINGS < 1 || \
ALEX2ESP_MAX_ACTION_MAPPINGS > 255 || ALEX2ESP_MAX_STATE_MAPPINGS < 1 || ALEX2ESP_MAX_STATE_MAPPINGS > 255
#error "ALEX2ESP_MAX_FRIENDLY_NAMES, ALEX2ESP_MAX_ACTION_MAPPINGS and ALEX2ESP_MAX_STATE_MAPPINGS have to be between 1 and 255"
#endif
#endif // ALEXA_LIMITS_H