Alex2ESP/test/test_messages/test_main.cpp
David 77156ffbe5 Report helpers for every tier-1 property, asset ids, AlexaCapability::isValid()
AlexaStatusMessage gets a helper for color, percentage, power level, range, mode, lock state, contact,
motion, humidity and the thermostat (target, lower and upper setpoint, mode), and addProperty(row, place,
value) for a property without one. Each is a function of its own on one shared builder; a value outside of
the range of its property is reported as the nearest of the range with an error.
AlexaResources.h holds the 103 asset ids of resources-and-assets.html (AlexaAssets, AlexaUnits); AlexaState
has the seven states that were missing; addStateMapping() takes a number of any type.
A setter that refuses its argument makes isValid() false, and the capability is left out of discovery with
an error instead of being announced without what was refused.
basicLight: static RAM 30,520 B (was 30,540), flash 333,389 B (was 334,981); 130 host tests (was 112).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 21:00:01 +00:00

799 lines
36 KiB
C++

// Host tests of the messages: what every kind of AlexaStatusMessage carries, byte for byte, and the topic it is
// published on (src/AlexaStatusMessage.cpp, the messages of src/AlexaDevice.cpp). The transport of the devices is
// a fake that keeps what would have been published.
// pio test -e native
#include <unity.h>
#include <ArduinoJson.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <string>
#include <vector>
#include "AlexaDevice.h"
#include "AlexaLog.h"
class FakeTransport : public AlexaTransport
{
public:
struct Message
{
std::string topic;
std::string json;
};
std::vector<Message> sent;
AlexaSendResult publish(const char *topic, JsonDocument &doc) override
{
Message message;
message.topic = topic;
serializeJson(doc, message.json);
sent.push_back(message);
return AlexaSendResult::OK;
}
void timestamp(char *buffer, size_t size) override { snprintf(buffer, size, "2026-09-28T13:05:09Z"); }
};
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;
}
};
static void assertLogged(const CapturedLog &log, const char *expected)
{
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(log.text.c_str(), expected), log.text.c_str());
}
static void assertIsUuid(const char *id)
{
TEST_ASSERT_EQUAL_MESSAGE(36, strlen(id), id);
for (int i = 0; i < 36; i++)
{
if (i == 8 || i == 13 || i == 18 || i == 23)
{
TEST_ASSERT_EQUAL_MESSAGE('-', id[i], id);
}
else
{
TEST_ASSERT_TRUE_MESSAGE(isdigit(id[i]) || (id[i] >= 'a' && id[i] <= 'f'), id);
}
}
TEST_ASSERT_EQUAL_MESSAGE('4', id[14], id);
TEST_ASSERT_NOT_NULL_MESSAGE(strchr("89ab", id[19]), id);
}
// The message as it was published. Its messageId is random: it is checked for its shape and replaced by "id".
static std::string published(const FakeTransport::Message &message)
{
JsonDocument doc;
TEST_ASSERT_TRUE(deserializeJson(doc, message.json) == DeserializationError::Ok);
assertIsUuid(doc["event"]["header"]["messageId"] | "");
doc["event"]["header"]["messageId"] = "id";
std::string text;
serializeJson(doc, text);
return text;
}
#define HEADER_OF(ns, name) "\"header\":{\"namespace\":\"" ns "\",\"name\":\"" name "\",\"payloadVersion\":\"3\",\"messageId\":\"id\""
#define TOKEN ",\"correlationToken\":\"token-of-the-directive\"}"
#define ENDPOINT ",\"endpoint\":{\"endpointId\":\"ESP-01\"}"
#define SAMPLED ",\"timeOfSample\":\"2026-09-28T13:05:09Z\",\"uncertaintyInMilliseconds\":0}"
#define HEALTH_OK "{\"namespace\":\"Alexa.EndpointHealth\",\"name\":\"connectivity\",\"value\":{\"value\":\"OK\"}" SAMPLED
#define POWER_ON "{\"namespace\":\"Alexa.PowerController\",\"name\":\"powerState\",\"value\":\"ON\"" SAMPLED
static const char TOKEN_OF_THE_DIRECTIVE[] = "token-of-the-directive";
// What a handler is given, for the answers a directive builds
static AlexaDirective directiveFor(AlexaDevice &device, const char *ns, const char *name)
{
AlexaDirective directive;
directive.device = &device;
directive.capability = nullptr;
directive.type = AlexaInterfaceType::UNKNOWN;
directive.ns = ns;
directive.name = name;
directive.instance = "";
directive.correlationToken = TOKEN_OF_THE_DIRECTIVE;
return directive;
}
void setUp(void)
{
AlexaLog::setOutput(nullptr);
}
void tearDown(void) {}
void test_response_carries_the_token_and_the_properties(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(lamp, "Alexa.PowerController", "TurnOn");
AlexaStatusMessage message = directive.response();
TEST_ASSERT_TRUE(AlexaMessageKind::RESPONSE == message.getKind());
TEST_ASSERT_TRUE(message.addHealthProp(EndpointHealth::OK).addPowerControllerProp(PowerController::ON).send());
TEST_ASSERT_EQUAL(1, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "Response") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":[" HEALTH_OK "," POWER_ON "]}}",
published(transport.sent[0]).c_str());
}
void test_state_report_carries_the_token_and_the_properties(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(lamp, "Alexa", "ReportState");
TEST_ASSERT_TRUE(directive.stateReport().addHealthProp(EndpointHealth::OK).addPowerControllerProp(PowerController::ON).send());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "StateReport") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":[" HEALTH_OK "," POWER_ON "]}}",
published(transport.sent[0]).c_str());
}
void test_names_of_1x_build_the_same_properties(void)
{
FakeTransport transport;
AlexaDevice blind("Blind", "root", "ESP-01", &transport);
blind.buildStatusMessage(TOKEN_OF_THE_DIRECTIVE, true)
.AddHealthProp(EndpointHealth::OK)
.AddPowerControllerProp(PowerController::ON)
.AddBrightnessControllerProp(40)
.AddColorTemperatureControllerProp(2700)
.AddToggleControllerProp(PowerController::OFF, "ESP-01.Toggle")
.send();
blind.response(TOKEN_OF_THE_DIRECTIVE)
.addHealthProp(EndpointHealth::OK)
.addPowerControllerProp(PowerController::ON)
.addBrightnessControllerProp(40)
.addColorTemperatureControllerProp(2700)
.addToggleControllerProp("ESP-01.Toggle", PowerController::OFF)
.send();
TEST_ASSERT_EQUAL(2, transport.sent.size());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "Response") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":[" HEALTH_OK "," POWER_ON ","
"{\"namespace\":\"Alexa.BrightnessController\",\"name\":\"brightness\",\"value\":40" SAMPLED ","
"{\"namespace\":\"Alexa.ColorTemperatureController\",\"name\":\"colorTemperatureInKelvin\",\"value\":2700" SAMPLED ","
"{\"namespace\":\"Alexa.ToggleController\",\"name\":\"toggleState\",\"instance\":\"ESP-01.Toggle\",\"value\":\"OFF\"" SAMPLED
"]}}",
published(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING(published(transport.sent[0]).c_str(), published(transport.sent[1]).c_str());
}
void test_temperature_is_reported_in_the_scale_it_is_given_in(void)
{
FakeTransport transport;
AlexaDevice sensor("Sensor", "root", "ESP-01", &transport);
sensor.response(TOKEN_OF_THE_DIRECTIVE)
.AddTemperatureSensorProp(TemperatureSensorScale::FAHRENHEIT, 69)
.addTemperatureSensorProp(20.5f)
.addTemperatureSensorProp(293.5f, TemperatureSensorScale::KELVIN, 500)
.send();
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "Response") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":["
"{\"namespace\":\"Alexa.TemperatureSensor\",\"name\":\"temperature\",\"value\":{\"value\":69,\"scale\":\"FAHRENHEIT\"}" SAMPLED ","
"{\"namespace\":\"Alexa.TemperatureSensor\",\"name\":\"temperature\",\"value\":{\"value\":20.5,\"scale\":\"CELSIUS\"}" SAMPLED ","
"{\"namespace\":\"Alexa.TemperatureSensor\",\"name\":\"temperature\",\"value\":{\"value\":293.5,\"scale\":\"KELVIN\"},"
"\"timeOfSample\":\"2026-09-28T13:05:09Z\",\"uncertaintyInMilliseconds\":500}"
"]}}",
published(transport.sent[0]).c_str());
}
void test_property_of_the_sketch_gets_the_time_in_place_of_the_placeholder(void)
{
FakeTransport transport;
AlexaDevice lock("Lock", "root", "ESP-01", &transport);
JsonDocument property;
property["namespace"] = "Alexa.LockController";
property["name"] = "lockState";
property["value"] = "LOCKED";
property["timeOfSample"] = "{REPLACE_WITH_DATETIME}";
property["uncertaintyInMilliseconds"] = 0;
lock.response(TOKEN_OF_THE_DIRECTIVE).addContextProp(property.as<JsonObject>()).send();
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "Response") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":[{\"namespace\":\"Alexa.LockController\",\"name\":\"lockState\",\"value\":\"LOCKED\"" SAMPLED "]}}",
published(transport.sent[0]).c_str());
}
void test_deferred_response_has_the_estimate_and_no_context(void)
{
FakeTransport transport;
AlexaDevice lock("Lock", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(lock, "Alexa.LockController", "Lock");
AlexaStatusMessage message = directive.deferred(7);
TEST_ASSERT_TRUE(AlexaMessageKind::DEFERRED_RESPONSE == message.getKind());
TEST_ASSERT_TRUE(message.addHealthProp(EndpointHealth::OK).send());
TEST_ASSERT_TRUE(directive.deferred().send());
TEST_ASSERT_EQUAL(2, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "DeferredResponse") TOKEN ENDPOINT ",\"payload\":{\"estimatedDeferralInSeconds\":7}}}",
published(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "DeferredResponse") TOKEN ENDPOINT ",\"payload\":{}}}",
published(transport.sent[1]).c_str());
}
void test_answer_after_a_deferred_response_goes_to_the_deferred_topic(void)
{
FakeTransport transport;
AlexaDevice lock("Lock", "root", "ESP-01", &transport);
TEST_ASSERT_TRUE(lock.response(TOKEN_OF_THE_DIRECTIVE).addHealthProp(EndpointHealth::OK).sendAsync());
TEST_ASSERT_TRUE(lock.response(TOKEN_OF_THE_DIRECTIVE)
.asErrorResponse(AlexaErrorType::DEVICE_STUCK, "The bolt does not move")
.sendAsync());
TEST_ASSERT_EQUAL(2, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/deferredResponse", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "Response") TOKEN ENDPOINT ",\"payload\":{}},"
"\"context\":{\"properties\":[" HEALTH_OK "]}}",
published(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/deferredResponse", transport.sent[1].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"DEVICE_STUCK\",\"message\":\"The bolt does not move\"}}}",
published(transport.sent[1]).c_str());
}
void test_send_async_refuses_what_is_no_answer_to_a_directive(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lock("Lock", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(lock, "Alexa.LockController", "Lock");
TEST_ASSERT_FALSE(directive.deferred(7).sendAsync());
TEST_ASSERT_FALSE(lock.changeReport(AlexaCause::PHYSICAL_INTERACTION).addHealthProp(EndpointHealth::OK).sendAsync());
TEST_ASSERT_EQUAL(0, transport.sent.size());
assertLogged(log, "error: message for ESP-01 not sent: sendAsync() is for the Response, StateReport or "
"ErrorResponse that follows a DeferredResponse, use send()");
}
void test_change_report_keeps_the_changed_properties_apart_from_the_others(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
AlexaStatusMessage message = lamp.changeReport(AlexaCause::PHYSICAL_INTERACTION);
TEST_ASSERT_TRUE(AlexaMessageKind::CHANGE_REPORT == message.getKind());
TEST_ASSERT_TRUE(message.addPowerControllerProp(PowerController::ON).context().addHealthProp(EndpointHealth::OK).send());
TEST_ASSERT_EQUAL(1, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/changeReport", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ChangeReport") "}" ENDPOINT
",\"payload\":{\"change\":{\"cause\":{\"type\":\"PHYSICAL_INTERACTION\"},\"properties\":[" POWER_ON "]}}},"
"\"context\":{\"properties\":[" HEALTH_OK "]}}",
published(transport.sent[0]).c_str());
}
void test_change_report_names_its_cause(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
const AlexaCause causes[] = {AlexaCause::APP_INTERACTION, AlexaCause::PERIODIC_POLL,
AlexaCause::PHYSICAL_INTERACTION, AlexaCause::VOICE_INTERACTION};
const char *const names[] = {"APP_INTERACTION", "PERIODIC_POLL", "PHYSICAL_INTERACTION", "VOICE_INTERACTION"};
for (int i = 0; i < 4; i++)
{
lamp.changeReport(causes[i]).addPowerControllerProp(PowerController::ON).send();
JsonDocument report;
TEST_ASSERT_TRUE(deserializeJson(report, transport.sent[i].json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING(names[i], report["event"]["payload"]["change"]["cause"]["type"] | "");
TEST_ASSERT_EQUAL(0, report["context"]["properties"].size());
}
}
void test_change_report_without_a_changed_property_is_refused_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
TEST_ASSERT_FALSE(lamp.changeReport(AlexaCause::PERIODIC_POLL).context().addHealthProp(EndpointHealth::OK).send());
TEST_ASSERT_EQUAL(0, transport.sent.size());
assertLogged(log, "error: change report for ESP-01 not sent: it has no property that changed, add one before context()");
}
void test_error_response_has_the_type_the_message_and_what_the_sketch_adds(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(lamp, "Alexa.BrightnessController", "SetBrightness");
AlexaStatusMessage message = directive.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "The brightness is 0 to 100");
TEST_ASSERT_TRUE(AlexaMessageKind::ERROR_RESPONSE == message.getKind());
message.payload()["validRange"]["minimumValue"] = 0;
message.payload()["validRange"]["maximumValue"] = 100;
TEST_ASSERT_TRUE(message.addHealthProp(EndpointHealth::OK).send());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"VALUE_OUT_OF_RANGE\",\"message\":\"The brightness is 0 to 100\","
"\"validRange\":{\"minimumValue\":0,\"maximumValue\":100}}}}",
published(transport.sent[0]).c_str());
}
void test_error_of_a_thermostat_is_sent_in_the_namespace_of_the_thermostat(void)
{
FakeTransport transport;
AlexaDevice thermostat("Thermostat", "root", "ESP-01", &transport);
AlexaDirective directive = directiveFor(thermostat, "Alexa.ThermostatController", "SetTargetTemperature");
directive.error(AlexaErrorType::REQUESTED_SETPOINTS_TOO_CLOSE, "The setpoints are 2 degrees apart or more").send();
directive.error(AlexaErrorType::UNWILLING_TO_SET_VALUE, "The window is open").send();
directive.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "10 to 30").send();
directive.error(AlexaErrorType::ALREADY_IN_OPERATION, "Heating").send();
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.ThermostatController", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"REQUESTED_SETPOINTS_TOO_CLOSE\",\"message\":\"The setpoints are 2 degrees apart or more\"}}}",
published(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.ThermostatController", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"UNWILLING_TO_SET_VALUE\",\"message\":\"The window is open\"}}}",
published(transport.sent[1]).c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"VALUE_OUT_OF_RANGE\",\"message\":\"10 to 30\"}}}",
published(transport.sent[2]).c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ErrorResponse") TOKEN ENDPOINT
",\"payload\":{\"type\":\"ALREADY_IN_OPERATION\",\"message\":\"Heating\"}}}",
published(transport.sent[3]).c_str());
}
void test_scene_answers_with_its_own_events(void)
{
FakeTransport transport;
AlexaDevice scene("Movie scene", "root", "ESP-01", &transport);
scene.addCapability(AlexaInterfaces::SceneController);
AlexaDirective directive = directiveFor(scene, "Alexa.SceneController", "Activate");
AlexaStatusMessage message = directive.sceneStarted();
TEST_ASSERT_TRUE(AlexaMessageKind::SCENE_EVENT == message.getKind());
TEST_ASSERT_TRUE(message.send());
TEST_ASSERT_TRUE(directive.sceneStopped().send());
TEST_ASSERT_EQUAL(2, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.SceneController", "ActivationStarted") TOKEN ENDPOINT
",\"payload\":{\"cause\":{\"type\":\"VOICE_INTERACTION\"},\"timestamp\":\"2026-09-28T13:05:09Z\"}},\"context\":{}}",
published(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING("root/ESP-01/alexaResponce", transport.sent[1].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.SceneController", "DeactivationStarted") TOKEN ENDPOINT
",\"payload\":{\"cause\":{\"type\":\"VOICE_INTERACTION\"},\"timestamp\":\"2026-09-28T13:05:09Z\"}},\"context\":{}}",
published(transport.sent[1]).c_str());
}
void test_doorbell_press_goes_to_the_event_topic(void)
{
FakeTransport transport;
AlexaDevice doorbell("Doorbell", "root", "ESP-01", &transport);
doorbell.addCapability(AlexaInterfaces::DoorbellEventSource);
AlexaStatusMessage message = doorbell.doorbellPress();
TEST_ASSERT_TRUE(AlexaMessageKind::EVENT == message.getKind());
TEST_ASSERT_TRUE(message.send());
TEST_ASSERT_EQUAL(1, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/event", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.DoorbellEventSource", "DoorbellPress") "}" ENDPOINT
",\"payload\":{\"cause\":{\"type\":\"PHYSICAL_INTERACTION\"},\"timestamp\":\"2026-09-28T13:05:09Z\"}},\"context\":{}}",
published(transport.sent[0]).c_str());
}
void test_event_of_an_interface_the_device_lacks_is_reported(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice button("Button", "root", "ESP-01", &transport);
button.addCapability(AlexaInterfaces::PowerController);
AlexaStatusMessage message = button.event(AlexaInterfaces::WakeOnLANController, "WakeUp");
assertLogged(log, "error: ESP-01: event of an interface the device does not have, Alexa will drop it: "
"add the capability with addCapability()");
TEST_ASSERT_TRUE(message.send());
TEST_ASSERT_EQUAL_STRING("root/event", transport.sent[0].topic.c_str());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa.WakeOnLANController", "WakeUp") "}" ENDPOINT ",\"payload\":{}},\"context\":{}}",
published(transport.sent[0]).c_str());
}
void test_every_message_has_an_id_of_its_own(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.response(TOKEN_OF_THE_DIRECTIVE).send();
lamp.response(TOKEN_OF_THE_DIRECTIVE).send();
JsonDocument first;
JsonDocument second;
TEST_ASSERT_TRUE(deserializeJson(first, transport.sent[0].json) == DeserializationError::Ok);
TEST_ASSERT_TRUE(deserializeJson(second, transport.sent[1].json) == DeserializationError::Ok);
assertIsUuid(first["event"]["header"]["messageId"] | "");
TEST_ASSERT_TRUE(strcmp(first["event"]["header"]["messageId"] | "", second["event"]["header"]["messageId"] | "") != 0);
}
static void sendsAChangeReportAndNoAnswer(AlexaDirective &directive)
{
directive.device->changeReport(AlexaCause::VOICE_INTERACTION).addPowerControllerProp(PowerController::ON).send();
}
void test_change_report_of_a_handler_is_not_its_answer(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
lamp.onDirective(sendsAChangeReportAndNoAnswer);
JsonDocument message;
message["header"]["namespace"] = "Alexa.PowerController";
message["header"]["name"] = "TurnOn";
message["header"]["correlationToken"] = TOKEN_OF_THE_DIRECTIVE;
message["endpoint"]["endpointId"] = "ESP-01";
message["payload"].to<JsonObject>();
lamp.handleDirective(message);
TEST_ASSERT_EQUAL(1, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/changeReport", transport.sent[0].topic.c_str());
assertLogged(log, "error: ESP-01: the handler sent no answer to Alexa.PowerController.TurnOn");
}
void test_message_is_sent_once(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
AlexaStatusMessage message = lamp.response(TOKEN_OF_THE_DIRECTIVE);
TEST_ASSERT_TRUE(message.addHealthProp(EndpointHealth::OK).send());
message.send();
TEST_ASSERT_EQUAL(2, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("null", transport.sent[1].json.c_str()); // the bridge refuses it as empty
}
// The properties of the message as it was published, without what is around them
static std::string propertiesOf(const FakeTransport::Message &message)
{
JsonDocument doc;
TEST_ASSERT_TRUE(deserializeJson(doc, message.json) == DeserializationError::Ok);
std::string text;
serializeJson(doc["context"]["properties"], text);
return text;
}
#define PROPERTY(ns, name, value) "{\"namespace\":\"" ns "\",\"name\":\"" name "\",\"value\":" value SAMPLED
#define PROPERTY_OF(ns, name, instance, value) \
"{\"namespace\":\"" ns "\",\"name\":\"" name "\",\"instance\":\"" instance "\",\"value\":" value SAMPLED
void test_numbers_of_a_light_are_reported_as_they_are(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.response(TOKEN_OF_THE_DIRECTIVE)
.addBrightnessControllerProp(40)
.addPercentageControllerProp(75)
.addPowerLevelControllerProp(0)
.addColorTemperatureControllerProp(10000)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.BrightnessController", "brightness", "40") ","
PROPERTY("Alexa.PercentageController", "percentage", "75") ","
PROPERTY("Alexa.PowerLevelController", "powerLevel", "0") ","
PROPERTY("Alexa.ColorTemperatureController", "colorTemperatureInKelvin", "10000") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_color_is_reported_as_hue_saturation_and_brightness(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.response(TOKEN_OF_THE_DIRECTIVE).addColorControllerProp(350.5f, 0.75f, 0.5f).send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.ColorController", "color", "{\"hue\":350.5,\"saturation\":0.75,\"brightness\":0.5}") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_generic_controllers_report_with_their_instance(void)
{
FakeTransport transport;
AlexaDevice fan("Fan", "root", "ESP-01", &transport);
fan.response(TOKEN_OF_THE_DIRECTIVE)
.addRangeControllerProp("Fan.Speed", 7.5f)
.addRangeControllerProp("Fan.Angle", 100)
.addModeControllerProp("Fan.Direction", "Direction.Forward")
.addToggleControllerProp("Fan.Oscillate", PowerController::ON)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY_OF("Alexa.RangeController", "rangeValue", "Fan.Speed", "7.5") ","
PROPERTY_OF("Alexa.RangeController", "rangeValue", "Fan.Angle", "100") ","
PROPERTY_OF("Alexa.ModeController", "mode", "Fan.Direction", "\"Direction.Forward\"") ","
PROPERTY_OF("Alexa.ToggleController", "toggleState", "Fan.Oscillate", "\"ON\"") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_lock_reports_each_of_its_states(void)
{
FakeTransport transport;
AlexaDevice lock("Lock", "root", "ESP-01", &transport);
lock.response(TOKEN_OF_THE_DIRECTIVE)
.addLockControllerProp(AlexaLockState::LOCKED)
.addLockControllerProp(AlexaLockState::UNLOCKED)
.addLockControllerProp(AlexaLockState::JAMMED, 2000)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.LockController", "lockState", "\"LOCKED\"") ","
PROPERTY("Alexa.LockController", "lockState", "\"UNLOCKED\"") ","
"{\"namespace\":\"Alexa.LockController\",\"name\":\"lockState\",\"value\":\"JAMMED\","
"\"timeOfSample\":\"2026-09-28T13:05:09Z\",\"uncertaintyInMilliseconds\":2000}]",
propertiesOf(transport.sent[0]).c_str());
}
void test_contact_and_motion_report_what_they_detect(void)
{
FakeTransport transport;
AlexaDevice door("Door", "root", "ESP-01", &transport);
door.response(TOKEN_OF_THE_DIRECTIVE)
.addContactSensorProp(true)
.addContactSensorProp(false)
.addMotionSensorProp(true)
.addMotionSensorProp(false)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.ContactSensor", "detectionState", "\"DETECTED\"") ","
PROPERTY("Alexa.ContactSensor", "detectionState", "\"NOT_DETECTED\"") ","
PROPERTY("Alexa.MotionSensor", "detectionState", "\"DETECTED\"") ","
PROPERTY("Alexa.MotionSensor", "detectionState", "\"NOT_DETECTED\"") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_humidity_is_reported_inside_an_object(void)
{
FakeTransport transport;
AlexaDevice sensor("Sensor", "root", "ESP-01", &transport);
sensor.response(TOKEN_OF_THE_DIRECTIVE).addHumiditySensorProp(45.5f).send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.HumiditySensor", "relativeHumidity", "{\"value\":45.5}") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_health_reports_a_device_that_cannot_be_reached(void)
{
FakeTransport transport;
AlexaDevice sensor("Sensor", "root", "ESP-01", &transport);
sensor.response(TOKEN_OF_THE_DIRECTIVE).addHealthProp(EndpointHealth::OK).addHealthProp(EndpointHealth::UNREACHABLE).send();
TEST_ASSERT_EQUAL_STRING(
"[" HEALTH_OK "," PROPERTY("Alexa.EndpointHealth", "connectivity", "{\"value\":\"UNREACHABLE\"}") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_thermostat_with_one_setpoint_reports_the_target_and_the_mode(void)
{
FakeTransport transport;
AlexaDevice thermostat("Thermostat", "root", "ESP-01", &transport);
thermostat.response(TOKEN_OF_THE_DIRECTIVE)
.addThermostatSetpointProp(21.5f)
.addThermostatModeProp(AlexaThermostatMode::HEAT)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.ThermostatController", "targetSetpoint", "{\"value\":21.5,\"scale\":\"CELSIUS\"}") ","
PROPERTY("Alexa.ThermostatController", "thermostatMode", "\"HEAT\"") "]",
propertiesOf(transport.sent[0]).c_str());
}
void test_thermostat_with_two_setpoints_reports_the_lower_and_the_upper(void)
{
FakeTransport transport;
AlexaDevice thermostat("Thermostat", "root", "ESP-01", &transport);
thermostat.response(TOKEN_OF_THE_DIRECTIVE)
.addThermostatDualSetpointProp(68, 76, TemperatureSensorScale::FAHRENHEIT)
.addThermostatModeProp(AlexaThermostatMode::AUTO)
.send();
thermostat.response(TOKEN_OF_THE_DIRECTIVE)
.addThermostatLowerSetpointProp(68, TemperatureSensorScale::FAHRENHEIT)
.addThermostatUpperSetpointProp(76, TemperatureSensorScale::FAHRENHEIT)
.addThermostatModeProp(AlexaThermostatMode::AUTO)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.ThermostatController", "lowerSetpoint", "{\"value\":68,\"scale\":\"FAHRENHEIT\"}") ","
PROPERTY("Alexa.ThermostatController", "upperSetpoint", "{\"value\":76,\"scale\":\"FAHRENHEIT\"}") ","
PROPERTY("Alexa.ThermostatController", "thermostatMode", "\"AUTO\"") "]",
propertiesOf(transport.sent[0]).c_str());
TEST_ASSERT_EQUAL_STRING(propertiesOf(transport.sent[0]).c_str(), propertiesOf(transport.sent[1]).c_str());
}
void test_every_thermostat_mode_has_the_name_of_alexa(void)
{
FakeTransport transport;
AlexaDevice thermostat("Thermostat", "root", "ESP-01", &transport);
const AlexaThermostatMode modes[] = {AlexaThermostatMode::AUTO, AlexaThermostatMode::COOL, AlexaThermostatMode::ECO,
AlexaThermostatMode::EM_HEAT, AlexaThermostatMode::HEAT, AlexaThermostatMode::OFF};
const char *const names[] = {"AUTO", "COOL", "ECO", "EM_HEAT", "HEAT", "OFF"};
for (int i = 0; i < 6; i++)
{
thermostat.response(TOKEN_OF_THE_DIRECTIVE).addThermostatModeProp(modes[i]).send();
JsonDocument report;
TEST_ASSERT_TRUE(deserializeJson(report, transport.sent[i].json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING(names[i], report["context"]["properties"][0]["value"] | "");
}
}
void test_value_outside_of_its_range_is_reported_as_the_nearest_with_an_error(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.response(TOKEN_OF_THE_DIRECTIVE)
.addBrightnessControllerProp(120)
.addPercentageControllerProp(-5)
.addColorTemperatureControllerProp(500)
.addHumiditySensorProp(100.5f)
.addColorControllerProp(400, -0.25f, 0.5f)
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.BrightnessController", "brightness", "100") ","
PROPERTY("Alexa.PercentageController", "percentage", "0") ","
PROPERTY("Alexa.ColorTemperatureController", "colorTemperatureInKelvin", "1000") ","
PROPERTY("Alexa.HumiditySensor", "relativeHumidity", "{\"value\":100}") ","
PROPERTY("Alexa.ColorController", "color", "{\"hue\":360,\"saturation\":0,\"brightness\":0.5}") "]",
propertiesOf(transport.sent[0]).c_str());
assertLogged(log, "error: ESP-01: Alexa.BrightnessController reported as 100: "
"the value was outside of 0 to 100, the range Alexa gives for it");
assertLogged(log, "error: ESP-01: Alexa.PercentageController reported as 0: the value was outside of 0 to 100");
assertLogged(log, "error: ESP-01: Alexa.ColorTemperatureController reported as 1000: the value was outside of 1000 to 10000");
assertLogged(log, "error: ESP-01: Alexa.HumiditySensor reported as 100: the value was outside of 0 to 100");
assertLogged(log, "error: ESP-01: Alexa.ColorController reported as 360: the value was outside of 0 to 360");
assertLogged(log, "error: ESP-01: Alexa.ColorController reported as 0: the value was outside of 0 to 1");
}
void test_property_without_a_function_is_added_by_its_place_in_the_row(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice speaker("Speaker", "root", "ESP-01", &transport);
JsonDocument values;
values["volume"] = 40;
values["muted"] = false;
values["level"]["@type"] = "Percentage";
values["level"]["value"] = 60;
speaker.response(TOKEN_OF_THE_DIRECTIVE)
.addProperty(AlexaInterfaces::Speaker, 0, values["volume"])
.addProperty(AlexaInterfaces::Speaker, 1, values["muted"], 250)
.addProperty(AlexaInterfaces::InventoryLevelSensor, 0, values["level"], 0, "Speaker.Battery")
.addProperty(AlexaInterfaces::Speaker, 2, values["volume"])
.addProperty(AlexaInterfaces::SceneController, 0, values["volume"])
.send();
TEST_ASSERT_EQUAL_STRING(
"[" PROPERTY("Alexa.Speaker", "volume", "40") ","
"{\"namespace\":\"Alexa.Speaker\",\"name\":\"muted\",\"value\":false,"
"\"timeOfSample\":\"2026-09-28T13:05:09Z\",\"uncertaintyInMilliseconds\":250},"
PROPERTY_OF("Alexa.InventoryLevelSensor", "level", "Speaker.Battery", "{\"@type\":\"Percentage\",\"value\":60}") "]",
propertiesOf(transport.sent[0]).c_str());
assertLogged(log, "error: ESP-01: property 2 of Alexa.Speaker not added: the interface has 2, the first of them is 0");
assertLogged(log, "error: ESP-01: property 0 of Alexa.SceneController not added: the interface has 0");
}
void test_change_report_takes_the_properties_of_every_function(void)
{
FakeTransport transport;
AlexaDevice door("Door", "root", "ESP-01", &transport);
TEST_ASSERT_TRUE(door.changeReport(AlexaCause::PHYSICAL_INTERACTION)
.addContactSensorProp(true)
.context()
.addLockControllerProp(AlexaLockState::UNLOCKED)
.addHealthProp(EndpointHealth::OK)
.send());
TEST_ASSERT_EQUAL_STRING(
"{\"event\":{" HEADER_OF("Alexa", "ChangeReport") "}" ENDPOINT
",\"payload\":{\"change\":{\"cause\":{\"type\":\"PHYSICAL_INTERACTION\"},\"properties\":["
PROPERTY("Alexa.ContactSensor", "detectionState", "\"DETECTED\"") "]}}},"
"\"context\":{\"properties\":[" PROPERTY("Alexa.LockController", "lockState", "\"UNLOCKED\"") "," HEALTH_OK "]}}",
published(transport.sent[0]).c_str());
}
int main(int, char **)
{
UNITY_BEGIN();
RUN_TEST(test_response_carries_the_token_and_the_properties);
RUN_TEST(test_state_report_carries_the_token_and_the_properties);
RUN_TEST(test_names_of_1x_build_the_same_properties);
RUN_TEST(test_temperature_is_reported_in_the_scale_it_is_given_in);
RUN_TEST(test_property_of_the_sketch_gets_the_time_in_place_of_the_placeholder);
RUN_TEST(test_deferred_response_has_the_estimate_and_no_context);
RUN_TEST(test_answer_after_a_deferred_response_goes_to_the_deferred_topic);
RUN_TEST(test_send_async_refuses_what_is_no_answer_to_a_directive);
RUN_TEST(test_change_report_keeps_the_changed_properties_apart_from_the_others);
RUN_TEST(test_change_report_names_its_cause);
RUN_TEST(test_change_report_without_a_changed_property_is_refused_with_an_error);
RUN_TEST(test_error_response_has_the_type_the_message_and_what_the_sketch_adds);
RUN_TEST(test_error_of_a_thermostat_is_sent_in_the_namespace_of_the_thermostat);
RUN_TEST(test_scene_answers_with_its_own_events);
RUN_TEST(test_doorbell_press_goes_to_the_event_topic);
RUN_TEST(test_event_of_an_interface_the_device_lacks_is_reported);
RUN_TEST(test_every_message_has_an_id_of_its_own);
RUN_TEST(test_change_report_of_a_handler_is_not_its_answer);
RUN_TEST(test_message_is_sent_once);
RUN_TEST(test_numbers_of_a_light_are_reported_as_they_are);
RUN_TEST(test_color_is_reported_as_hue_saturation_and_brightness);
RUN_TEST(test_generic_controllers_report_with_their_instance);
RUN_TEST(test_lock_reports_each_of_its_states);
RUN_TEST(test_contact_and_motion_report_what_they_detect);
RUN_TEST(test_humidity_is_reported_inside_an_object);
RUN_TEST(test_health_reports_a_device_that_cannot_be_reached);
RUN_TEST(test_thermostat_with_one_setpoint_reports_the_target_and_the_mode);
RUN_TEST(test_thermostat_with_two_setpoints_reports_the_lower_and_the_upper);
RUN_TEST(test_every_thermostat_mode_has_the_name_of_alexa);
RUN_TEST(test_value_outside_of_its_range_is_reported_as_the_nearest_with_an_error);
RUN_TEST(test_property_without_a_function_is_added_by_its_place_in_the_row);
RUN_TEST(test_change_report_takes_the_properties_of_every_function);
return UNITY_END();
}