AlexaStatusMessage: DeferredResponse, ChangeReport, ErrorResponse, scene and doorbell events, UUID-shaped messageId
A message has a kind, and the kind decides its topic: answers on <root>/<id>/alexaResponce, the answer after a DeferredResponse on <root>/<id>/deferredResponse (sendAsync()), a ChangeReport on <root>/changeReport with the changed properties apart from the others (context()), DoorbellPress on <root>/event. AlexaDirective gains deferred(), error(type, message), sceneStarted() and sceneStopped(); thermostat error types go out in the namespace of the thermostat. The helpers are add*Prop, the Add*Prop names stay; a temperature keeps its scale (69 FAHRENHEIT, was 20.56 CELSIUS). messageId is a version 4 UUID from the hardware random source, where rand() seeded per second gave two messages one id. basicLight: static RAM 30,540 B (-76), flash 334,981 B (+1,280); 112 host tests (bridge logic 52, messages 19, new). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
42af413a6e
commit
3215457f91
12 changed files with 1093 additions and 90 deletions
|
|
@ -1,8 +1,8 @@
|
|||
; Development project of the library itself. A sketch does not need this file: it gets the library through
|
||||
; lib_deps (readme.md, Quick Start).
|
||||
;
|
||||
; pio test -e native host tests of the bridge logic (src/AlexaBridgeLogic.cpp), of discovery and of dispatch
|
||||
; (src/AlexaDevice.cpp, src/AlexaCapability.cpp, src/AlexaInterfaces.cpp,
|
||||
; pio test -e native host tests of the bridge logic (src/AlexaBridgeLogic.cpp), of discovery, of dispatch and
|
||||
; of the messages (src/AlexaDevice.cpp, src/AlexaCapability.cpp, src/AlexaInterfaces.cpp,
|
||||
; src/AlexaStatusMessage.cpp); no board, no broker
|
||||
|
||||
[platformio]
|
||||
|
|
|
|||
82
readme.md
82
readme.md
|
|
@ -199,7 +199,30 @@ Everything goes over MQTT (port 1883 of `alex2mqtt.stormysdream.club`); the libr
|
|||
- **Session.** `begin()` starts SNTP (`pool.ntp.org`, `time.nist.gov`) and returns; `loop()` opens the MQTT session once Wi-Fi is up and the clock is set, or after 5 s of Wi-Fi without an answer, and subscribes to `<root>/discover` and `<root>/+/alexaDirective`. `getState()` is `CONNECTED` when the broker has acknowledged both subscriptions; `[Alex2ESP] error: the broker refused the subscription to <root>/discover` means that the root topic is not the one of the account. A session that ends or cannot be opened prints `[Alex2ESP] error: disconnected: <reason>; next attempt in N s` and is opened again after 1 s, then 2 s, 4 s and so on up to once a minute, for as long as Wi-Fi is up. The wait starts at 1 s again after a session that lasted a minute: a session that the broker closes right after it has accepted it, as it does to one of two boards with the same client id, is opened again after the longer wait. `the broker refused the username or the password` is the reason to look for when a board never shows up in Alexa, `the broker did not answer within 30 s` stands for an attempt that the library gave up. When the link goes down the library prints `[Alex2ESP] error: Wi-Fi is down, waiting for it`, once per loss; the MQTT client reports the lost connection when its keep-alive runs out, and the session is opened again when Wi-Fi is back. A sketch that sets the clock itself (its own `configTime()` with a time zone, an RTC) calls `alexClient.setTimeSource(false)` before `begin()`. The board has to reach an NTP server: DNS for the two names and outbound UDP port 123. Without the time of day the session still opens, the reports carry a `timeOfSample` in 1970, and the library prints `[Alex2ESP] error: the clock is not set, ...` when it connects and then at most once a minute while reports are sent.
|
||||
- **Discovery.** On `<root>/discover` the library answers with one discovery object per device on `<root>/discover_r`. The backend accepts one endpoint object per message and collects everything that arrives within 1 s for Alexa's discovery answer (up to 5 s for its proactive AddOrUpdate push), so all devices are published back to back from the next `loop()`. Each object sits on the heap (about 1 KB) until the MQTT client has sent it; when the client cannot take another one (free heap under 4 KB), the library prints `[Alex2ESP] discovery deferred at <endpointId>` and sends the rest from `loop()` as the queue drains, for up to 5 s after the request. `[Alex2ESP] error: discovery gave up: N device(s) not announced` means those devices missed this answer - on the backend's proactive discovery that can remove them from Alexa until the next one.
|
||||
- **Directives.** The directive arrives as JSON on `<root>/<endpointId>/alexaDirective`. A directive larger than one TCP segment arrives in fragments, which are put together in one heap block that exists only until `loop()` has parsed it. `loop()` then calls the handler of the device: the one of `onDirective()`, or else `ReportState` or `Event` of `registerEvent()` (and before it `DirectiveReceived`, if registered) with the directive: `directive["header"]`, `directive["endpoint"]`, `directive["payload"]`. A device without a handler answers with the ErrorResponse `INVALID_DIRECTIVE`, and so does a device whose handler sent nothing for a directive that names a capability the device does not have; both print an error. A handler that sends nothing for a capability of its device leaves the directive unanswered, which prints `[Alex2ESP] error: <endpointId>: the handler sent no answer to ...`. Every call of `loop()` handles one directive, in the order of arrival. Up to eight directives wait for it: Alexa sends a group command ("turn off the kitchen") as one directive per endpoint, and they arrive faster than a busy sketch calls `loop()`. A directive that arrives twice (a broker that mirrors its topics delivers every message twice) is handled once; the repeat is recognised while it arrives and takes no place among the waiting ones.
|
||||
- **Reports.** `send()` publishes the report on `<root>/<endpointId>/alexaResponce` at once. The backend waits 7 s for it, so answer from the event handler. Every property carries the board's UTC time as `timeOfSample`.
|
||||
- **Reports.** `send()` publishes a message at once, on the topic of its kind. Every property carries the board's UTC time as `timeOfSample`, every message a `messageId` of its own, a UUID made of the random source of the hardware.
|
||||
|
||||
| Kind | Built with | Topic |
|
||||
|---|---|---|
|
||||
| Response | `d.response()` | `<root>/<endpointId>/alexaResponce` |
|
||||
| StateReport | `d.stateReport()` | `<root>/<endpointId>/alexaResponce` |
|
||||
| ErrorResponse | `d.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "0 to 100")` | `<root>/<endpointId>/alexaResponce` |
|
||||
| DeferredResponse | `d.deferred(7)` | `<root>/<endpointId>/alexaResponce` |
|
||||
| the answer after it | `device->response(token)` ... `sendAsync()` | `<root>/<endpointId>/deferredResponse` |
|
||||
| ActivationStarted, DeactivationStarted | `d.sceneStarted()`, `d.sceneStopped()` | `<root>/<endpointId>/alexaResponce` |
|
||||
| ChangeReport | `device->changeReport(AlexaCause::PHYSICAL_INTERACTION)` | `<root>/changeReport` |
|
||||
| DoorbellPress | `device->doorbellPress()` | `<root>/event` |
|
||||
|
||||
The backend waits 7 s for the answer to a directive, so answer from the handler. A device that takes longer (Alexa accepts this for locks and Wake-on-LAN) sends `d.deferred(seconds)`, keeps a copy of `d.correlationToken` and sends the answer when it is done: `device->response(token).addHealthProp(EndpointHealth::OK).sendAsync()`. An `ErrorResponse` takes what its type asks for in `payload()`, for example `payload()["validRange"]["maximumValue"] = 100`; the types of a thermostat (`REQUESTED_SETPOINTS_TOO_CLOSE`, ...) are sent in the namespace `Alexa.ThermostatController`. A `ChangeReport` tells Alexa that properties of a capability with `setProactivelyReported(true)` changed: the properties added before `context()` are those that changed, the ones after it the others.
|
||||
|
||||
```cpp
|
||||
device->changeReport(AlexaCause::PHYSICAL_INTERACTION)
|
||||
.addPowerControllerProp(PowerController::ON)
|
||||
.context()
|
||||
.addHealthProp(EndpointHealth::OK)
|
||||
.send();
|
||||
```
|
||||
|
||||
A `ChangeReport` without a property that changed is not sent and prints an error.
|
||||
- **Limits.** A directive may be 2047 bytes (`ALEX2ESP_MAX_DIRECTIVE`), a report or the discovery object of one device 3071 (`ALEX2ESP_MAX_MESSAGE`). The second limit is the first plus 1024 unless it is set: an answer repeats the `correlationToken` of its directive, which is most of a large directive, and adds 140 to 170 bytes per property, so the largest directive can be answered with six properties. Eight directives (`ALEX2ESP_MAX_QUEUED_DIRECTIVES`) of 8188 bytes together (`ALEX2ESP_MAX_QUEUED_BYTES`, four times the largest directive) may wait for `loop()`; one that finds no place is dropped with `[Alex2ESP] error: directive of N bytes on <topic> dropped: ...`. `-D<name>=<value>` in `build_flags` changes a limit. `send()` returns `false` when the report was not sent: no session with the broker, the MQTT client or the heap cannot take it, or it is too large. Nothing is ever sent truncated, and nothing is dropped without a line on Serial.
|
||||
- **Serial output.** Every line of the library starts with `[Alex2ESP]`, a problem with `[Alex2ESP] error:`. `alexClient.setLogLevel(AlexaLogLevel::ERROR)` leaves only the problems, `AlexaLogLevel::NONE` nothing; the default, `AlexaLogLevel::INFO`, adds the session, discovery and one line per directive (`[Alex2ESP] ESP-01 <- Alexa.PowerController.TurnOn`). `AlexaLogLevel::DEBUG` (sizes and free heap per message) has to be compiled in with `-DALEX2ESP_LOG_MAX=3`; `-DALEX2ESP_LOG_MAX=0` compiles every line out. The username, the password, the root topic and correlation tokens are never printed, at any level, so a log can be posted as it is: a topic appears without its root (`ESP-01/alexaResponce`), a directive under its endpoint id. A sketch that defines a macro named `DEBUG`, `ERROR` or `INFO` cannot write the level of that name; it passes the number instead, for example `alexClient.setLogLevel(static_cast<AlexaLogLevel>(3))` for `DEBUG`.
|
||||
|
||||
|
|
@ -209,32 +232,32 @@ Boards that run 1.1.0 or older keep working: the backend still publishes the tok
|
|||
|
||||
## Interface Types
|
||||
|
||||
The library describes these interfaces. `addCapability()` takes the row, `AlexaInterfaces::PowerController`, or the type as 1.x sketches write it, `AlexaInterfaceType::POWER_CONTROLLER`. A sketch links the rows it names and no others.
|
||||
The library describes these interfaces. `addCapability()` takes the row, `AlexaInterfaces::PowerController`, or the type as 1.x sketches write it, `AlexaInterfaceType::POWER_CONTROLLER`. A sketch links the rows it names and no others. The report helpers of 1.x, `AddHealthProp` and the others with a capital letter, stay as names of the ones in the table.
|
||||
|
||||
| Row in `AlexaInterfaces` | `AlexaInterfaceType::` | Version | Properties | Report helper |
|
||||
|---|---|---|---|---|
|
||||
| `EndpointHealth` | `ENDPOINT_HEALTH` | 3.1 | connectivity | `AddHealthProp` |
|
||||
| `PowerController` | `POWER_CONTROLLER` | 3 | powerState | `AddPowerControllerProp` |
|
||||
| `BrightnessController` | `BRIGHTNESS_CONTROLLER` | 3 | brightness | `AddBrightnessControllerProp` |
|
||||
| `ColorTemperatureController` | `COLOR_TEMPERATURE_CONTROLLER` | 3 | colorTemperatureInKelvin | `AddColorTemperatureControllerProp` |
|
||||
| `ToggleController` | `TOGGLE_CONTROLLER` | 3 | toggleState | `AddToggleControllerProp` |
|
||||
| `TemperatureSensor` | `TEMPERATURE_SENSOR` | 3 | temperature | `AddTemperatureSensorProp` |
|
||||
| `ColorController` | `COLOR_CONTROLLER` | 3 | color | `AddContextProp` |
|
||||
| `PowerLevelController` | `POWER_LEVEL_CONTROLLER` | 3 | powerLevel | `AddContextProp` |
|
||||
| `PercentageController` | `PERCENTAGE_CONTROLLER` | 3 | percentage | `AddContextProp` |
|
||||
| `RangeController` | `RANGE_CONTROLLER` | 3 | rangeValue | `AddContextProp` |
|
||||
| `ModeController` | `MODE_CONTROLLER` | 3 | mode | `AddContextProp` |
|
||||
| `ThermostatController` | `THERMOSTAT_CONTROLLER` | 3.2 | targetSetpoint, lowerSetpoint, upperSetpoint, thermostatMode | `AddContextProp` |
|
||||
| `HumiditySensor` | `HUMIDITY_SENSOR` | 3 | relativeHumidity | `AddContextProp` |
|
||||
| `LockController` | `LOCK_CONTROLLER` | 3 | lockState | `AddContextProp` |
|
||||
| `ContactSensor` | `CONTACT_SENSOR` | 3 | detectionState | `AddContextProp` |
|
||||
| `MotionSensor` | `MOTION_SENSOR` | 3 | detectionState | `AddContextProp` |
|
||||
| `TimeHoldController` | `TIME_HOLD_CONTROLLER` | 3 | holdStartTime, holdEndTime | `AddContextProp` |
|
||||
| `Speaker` | `SPEAKER` | 3 | volume, muted | `AddContextProp` |
|
||||
| `PlaybackStateReporter` | `PLAYBACK_STATE_REPORTER` | 3 | playbackState | `AddContextProp` |
|
||||
| `InputController` | `INPUT_CONTROLLER` | 3 | input | `AddContextProp` |
|
||||
| `ChannelController` | `CHANNEL_CONTROLLER` | 3 | channel | `AddContextProp` |
|
||||
| `InventoryLevelSensor` | `INVENTORY_LEVEL_SENSOR` | 3 | level | `AddContextProp` |
|
||||
| `EndpointHealth` | `ENDPOINT_HEALTH` | 3.1 | connectivity | `addHealthProp` |
|
||||
| `PowerController` | `POWER_CONTROLLER` | 3 | powerState | `addPowerControllerProp` |
|
||||
| `BrightnessController` | `BRIGHTNESS_CONTROLLER` | 3 | brightness | `addBrightnessControllerProp` |
|
||||
| `ColorTemperatureController` | `COLOR_TEMPERATURE_CONTROLLER` | 3 | colorTemperatureInKelvin | `addColorTemperatureControllerProp` |
|
||||
| `ToggleController` | `TOGGLE_CONTROLLER` | 3 | toggleState | `addToggleControllerProp` |
|
||||
| `TemperatureSensor` | `TEMPERATURE_SENSOR` | 3 | temperature | `addTemperatureSensorProp` |
|
||||
| `ColorController` | `COLOR_CONTROLLER` | 3 | color | `addContextProp` |
|
||||
| `PowerLevelController` | `POWER_LEVEL_CONTROLLER` | 3 | powerLevel | `addContextProp` |
|
||||
| `PercentageController` | `PERCENTAGE_CONTROLLER` | 3 | percentage | `addContextProp` |
|
||||
| `RangeController` | `RANGE_CONTROLLER` | 3 | rangeValue | `addContextProp` |
|
||||
| `ModeController` | `MODE_CONTROLLER` | 3 | mode | `addContextProp` |
|
||||
| `ThermostatController` | `THERMOSTAT_CONTROLLER` | 3.2 | targetSetpoint, lowerSetpoint, upperSetpoint, thermostatMode | `addContextProp` |
|
||||
| `HumiditySensor` | `HUMIDITY_SENSOR` | 3 | relativeHumidity | `addContextProp` |
|
||||
| `LockController` | `LOCK_CONTROLLER` | 3 | lockState | `addContextProp` |
|
||||
| `ContactSensor` | `CONTACT_SENSOR` | 3 | detectionState | `addContextProp` |
|
||||
| `MotionSensor` | `MOTION_SENSOR` | 3 | detectionState | `addContextProp` |
|
||||
| `TimeHoldController` | `TIME_HOLD_CONTROLLER` | 3 | holdStartTime, holdEndTime | `addContextProp` |
|
||||
| `Speaker` | `SPEAKER` | 3 | volume, muted | `addContextProp` |
|
||||
| `PlaybackStateReporter` | `PLAYBACK_STATE_REPORTER` | 3 | playbackState | `addContextProp` |
|
||||
| `InputController` | `INPUT_CONTROLLER` | 3 | input | `addContextProp` |
|
||||
| `ChannelController` | `CHANNEL_CONTROLLER` | 3 | channel | `addContextProp` |
|
||||
| `InventoryLevelSensor` | `INVENTORY_LEVEL_SENSOR` | 3 | level | `addContextProp` |
|
||||
| `PlaybackController` | `PLAYBACK_CONTROLLER` | 3 | `"properties": {}` | |
|
||||
| `WakeOnLANController` | `WAKE_ON_LAN_CONTROLLER` | 3 | `"properties": {}` | |
|
||||
| `SceneController` | `SCENE_CONTROLLER` | 3 | no `properties` object | |
|
||||
|
|
@ -294,7 +317,12 @@ Behaviour changes:
|
|||
- The handler of `Event` gets the type of the capability of its device that the directive names, and `AlexaInterfaceType::UNKNOWN` for a namespace the device has no capability for. 1.1.0 looked the namespace up among all interfaces.
|
||||
- Removed: `AlexaInterfaceType::AUTHORIZATION_CONTROLLER` and `AUTOMOTIVE_VEHICLE_DATA`, which Alexa has withdrawn. `AlexaInterfaceUtils` (`toString`, `fromString`, `getVersion`, `getProps`): a row has the namespace, the version and the properties, `alexaInterfaceRow(type)` gives the row of a type. The constructor of `AlexaInterface` takes a row. `DisplayCategory` and `AlexaInterfaceType` are one byte wide.
|
||||
- New: `AlexaCapability`, which `addCapability()` returns. `addCapability(AlexaInterfaces::RangeController, "Blind.Lift")` takes the instance of an interface that a device may have several of, and a device can have several capabilities of one such interface. `addFriendlyAsset()`, `setConfiguration()`, `addStateMapping()` and `setNonControllable()` are new; every setter returns the capability. `matches(namespace, instance)` tells whether a directive is for the capability. `AlexaInterface` and `AlexaActions` stay as names of `AlexaCapability` and `AlexaAction`, so a 1.x sketch compiles as it is.
|
||||
- New: `AlexaDevice::onDirective(handler)`. The handler, `void handler(AlexaDirective& d)`, gets every directive of its device with the device (`d.device`), the capability it is for (`d.capability`, `d.type`), its `ns`, `name`, `instance`, `correlationToken` and `payload`, and builds the answer with `d.response()` or `d.stateReport()`; one function can serve several devices. `registerEvent()` stays for the handlers of 1.x, which a device calls when it has no handler of `onDirective()`. `AlexaDevice::findCapability(namespace, instance)`, `AlexaStatusMessage::asErrorResponse(type, message)`.
|
||||
- New: `AlexaDevice::onDirective(handler)`. The handler, `void handler(AlexaDirective& d)`, gets every directive of its device with the device (`d.device`), the capability it is for (`d.capability`, `d.type`), its `ns`, `name`, `instance`, `correlationToken` and `payload`, and builds the answer with `d.response()`, `d.stateReport()`, `d.error(type, message)`, `d.deferred(seconds)` or, for a scene, `d.sceneStarted()` and `d.sceneStopped()`; one function can serve several devices. `registerEvent()` stays for the handlers of 1.x, which a device calls when it has no handler of `onDirective()`. `AlexaDevice::findCapability(namespace, instance)`.
|
||||
- New kinds of messages beside `Response` and `StateReport`: `ErrorResponse` (`AlexaErrorType` has the types of Alexa, `asErrorResponse(type, message)` turns an answer into one), `DeferredResponse` and the answer that follows it (`sendAsync()`, on `<root>/<endpointId>/deferredResponse`), `ChangeReport` (`AlexaDevice::changeReport(cause)`, on `<root>/changeReport`), the events of a scene, and `DoorbellPress` (`AlexaDevice::doorbellPress()`, or `event(row, name)` for another interface, on `<root>/event`). `AlexaDevice::response(token)` and `stateReport(token)` build an answer outside of the handler. `payload()` gives the payload of a message, `getKind()` its kind.
|
||||
- The report helpers begin with a small letter: `addHealthProp`, `addPowerControllerProp`, `addBrightnessControllerProp`, `addColorTemperatureControllerProp`, `addToggleControllerProp(instance, state)`, `addTemperatureSensorProp(value, scale)`, `addContextProp`. The names of 1.x stay, with their order of arguments.
|
||||
- A temperature is reported in the scale it is given in: `AddTemperatureSensorProp(TemperatureSensorScale::FAHRENHEIT, 69)` reports 69 `FAHRENHEIT`, where 1.1.0 reported 20.56 `CELSIUS`. `TemperatureSensorScale::KELVIN` is new.
|
||||
- `messageId` is a UUID of version 4 from the random source of the hardware. 1.1.0 sent 37 characters from `rand()`, seeded with the time in seconds: two messages of one second had the same id.
|
||||
- A `ChangeReport` or an event that a handler sends is not taken for the answer to its directive.
|
||||
- A directive that nothing answers is answered by the library with the ErrorResponse `INVALID_DIRECTIVE`: when the device has no handler for it, and when it names a capability that the device does not have and the handler sent nothing. 1.1.0 left both to the timeout, after which Alexa says that the device does not respond. A handler that sends nothing for a capability of its device prints an error; nothing is sent for it.
|
||||
- Devices are a linked list, and a device holds its capabilities in an array of 8 pointers (`ALEX2ESP_MAX_CAPABILITIES`); both were a `std::deque`. A ninth capability is refused with an error and `addCapability()` returns `nullptr`. `getDevice()` returns `nullptr` with an error when the heap has no room for the device. An `AlexaDevice` cannot be copied. A directive whose topic names a device of the board and whose `endpointId` does not is ignored with a line at `DEBUG` (it was an error).
|
||||
- A capability takes 116 bytes of heap and holds 3 friendly names, 4 action mappings and 2 state mappings (`ALEX2ESP_MAX_FRIENDLY_NAMES`, `ALEX2ESP_MAX_ACTION_MAPPINGS`, `ALEX2ESP_MAX_STATE_MAPPINGS`); one more is refused with an error. The instance and the text of a friendly name are copied, as before. The locale and the directive name and payload of an `ActionMapping` are kept as pointers, where 1.1.0 copied them: pass literals. An `ActionMapping` announces its actions in the order of `AlexaAction`.
|
||||
|
|
@ -302,9 +330,9 @@ Behaviour changes:
|
|||
- `PlaybackController` and `WakeOnLANController` are announced with `"properties": {}`, as their pages show them.
|
||||
- Removed: `AlexaInterface::getJSON()` (`toJson()` adds the capability to the capabilities of its endpoint) and `getProps()` (the row has the properties), `ActionMapping::getJSON()` and the `String` and `std::vector` members of `ActionMapping`, the class `FriendlyName`.
|
||||
|
||||
Memory: `examples/basicLight.cpp` for a D1 mini takes 30,616 bytes of static RAM (1.1.0: 52,768) and 333,701 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). 18,260 bytes of the static RAM were the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients; 3,720 were the names, versions and properties of all interfaces and the names of the display categories, which are in flash now. SNTP and the time stamp are 1.8 KB of the flash figure. The instance, names, configuration and mappings of a capability are 2.0 KB of it, the directive handler and the ErrorResponse 0.9 KB.
|
||||
Memory: `examples/basicLight.cpp` for a D1 mini takes 30,540 bytes of static RAM (1.1.0: 52,768) and 334,981 bytes of flash (1.1.0: 350,885), as PlatformIO reports them (espressif8266 4.2.1, Arduino core 3.1.2). 18,260 bytes of the static RAM were the five 2 KB queue slots, three more 2 KB buffers and the two HTTP clients; 3,720 were the names, versions and properties of all interfaces and the names of the display categories, which are in flash now. SNTP and the time stamp are 1.8 KB of the flash figure. The instance, names, configuration and mappings of a capability are 2.0 KB of it, the directive handler and the ErrorResponse 0.9 KB, the kinds of messages and the UUID 1.3 KB. A kind that a sketch does not send is not linked.
|
||||
|
||||
Tests: `pio test -e native` in the repository runs 91 host tests: 50 of the receive and publish logic (reassembly of fragments, the directives that wait for `loop()`, repeated directives, the size limits, a heap without room, the wait between reconnects, topics, time stamps), 30 of discovery (the discovery object of every example against what 1.1.0 announced, the 28 rows against the interface pages, a type without a row, the discovery objects of a range, a mode and a toggle controller, a scene and a doorbell, what a capability refuses) and 11 of dispatch (the device and the capability a directive reaches, one handler for two devices, the handlers of 1.x, a capability more than a device holds, the answers to a directive for a capability the device lacks and to one without a handler). No board is needed.
|
||||
Tests: `pio test -e native` in the repository runs 112 host tests: 52 of the receive and publish logic (reassembly of fragments, the directives that wait for `loop()`, repeated directives, the size limits, a heap without room, the wait between reconnects, topics, time stamps, the message id), 30 of discovery (the discovery object of every example against what 1.1.0 announced, the 28 rows against the interface pages, a type without a row, the discovery objects of a range, a mode and a toggle controller, a scene and a doorbell, what a capability refuses), 11 of dispatch (the device and the capability a directive reaches, one handler for two devices, the handlers of 1.x, a capability more than a device holds, the answers to a directive for a capability the device lacks and to one without a handler) and 19 of the messages (every kind against the JSON and the topic it is published with, the names of 1.x, the scale of a temperature, what `send()` and `sendAsync()` refuse). No board is needed.
|
||||
|
||||
Packaging: `library.json`, `library.properties` and the `softwareVersion` and `firmwareVersion` that a device reports in discovery say 1.2.0; the library has the number in one place, `ALEX2ESP_VERSION` in `src/AlexaVersion.h`. The `platformio.ini` of the repository is for the host tests; a sketch does not need it.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
* <root>/discover in answered with one discovery object per device on <root>/discover_r
|
||||
* <root>/<endpointId>/alexaDirective in the directive, handed to the handler of the device (onDirective(), or
|
||||
* the ReportState / Event handlers of 1.x)
|
||||
* <root>/<endpointId>/alexaResponce out the report the handler built with buildStatusMessage()
|
||||
* <root>/<endpointId>/alexaResponce out the answer the handler built: Response, StateReport, ErrorResponse,
|
||||
* DeferredResponse, the events of a scene
|
||||
* <root>/<endpointId>/deferredResponse out the answer that follows a DeferredResponse (sendAsync())
|
||||
* <root>/changeReport out ChangeReport: properties that changed without a directive
|
||||
* <root>/event out what a device reports unasked, DoorbellPress
|
||||
*/
|
||||
|
||||
#ifndef ALEX2ESP_H
|
||||
|
|
|
|||
|
|
@ -260,6 +260,40 @@ bool AlexaBridgeLogic::formatTimestamp(time_t instant, char *buffer, size_t size
|
|||
return true;
|
||||
}
|
||||
|
||||
bool AlexaBridgeLogic::formatMessageId(const uint32_t random[4], char *buffer, size_t size)
|
||||
{
|
||||
if (buffer == nullptr || size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (size < ALEXA_MESSAGE_ID_SIZE)
|
||||
{
|
||||
buffer[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
char *next = buffer;
|
||||
for (uint8_t digit = 0; digit < 32; digit++)
|
||||
{
|
||||
if (digit == 8 || digit == 12 || digit == 16 || digit == 20)
|
||||
{
|
||||
*next++ = '-';
|
||||
}
|
||||
uint8_t value = (random[digit / 8] >> (28 - 4 * (digit % 8))) & 0x0F;
|
||||
if (digit == 12)
|
||||
{
|
||||
value = 4; // the version: made of random numbers
|
||||
}
|
||||
else if (digit == 16)
|
||||
{
|
||||
value = 8 | (value & 3); // the variant: 8, 9, a or b
|
||||
}
|
||||
*next++ = static_cast<char>(value < 10 ? '0' + value : 'a' + value - 10);
|
||||
}
|
||||
*next = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *AlexaBridgeLogic::directiveEndpoint(const char *topic, const char *rootTopic, size_t *length)
|
||||
{
|
||||
static const char suffix[] = "/alexaDirective";
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
#include "AlexaLimits.h"
|
||||
#include "AlexaTransport.h"
|
||||
|
||||
// "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" and the terminating NUL
|
||||
#define ALEXA_MESSAGE_ID_SIZE 37
|
||||
|
||||
// The last values it was given, as many as CAPACITY: what a repeat is recognised by.
|
||||
class AlexaRecentHashes
|
||||
{
|
||||
|
|
@ -213,6 +216,11 @@ namespace AlexaBridgeLogic
|
|||
// is smaller than ALEXA_TIMESTAMP_SIZE or the year has more than four digits.
|
||||
bool formatTimestamp(time_t instant, char *buffer, size_t size);
|
||||
|
||||
// Writes 128 random bits as the messageId of a message: a UUID of version 4 (RFC 4122), which keeps 122 of
|
||||
// them, in lower case. Returns false and leaves an empty string when the buffer is smaller than
|
||||
// ALEXA_MESSAGE_ID_SIZE.
|
||||
bool formatMessageId(const uint32_t random[4], char *buffer, size_t size);
|
||||
|
||||
// The endpoint id in "<root>/<endpointId>/alexaDirective": a pointer into the topic and the id's length.
|
||||
// nullptr for any other topic, among them <root>/discover and the <root>/<endpointId>/alexaDirective_e token
|
||||
// topic that 1.x boards use.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Lets the sources that have no hardware dependency compile on a host (pio test -e native), where the
|
||||
// program-memory helpers of the Arduino cores do not exist: there a PROGMEM object is an ordinary constant and a
|
||||
// pointer to one an ordinary pointer. On a board this is Arduino.h and nothing else.
|
||||
// pointer to one an ordinary pointer. On a board this is Arduino.h and the random source of the hardware.
|
||||
#ifndef ALEXA_COMPAT_H
|
||||
#define ALEXA_COMPAT_H
|
||||
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
#else
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <random>
|
||||
#define PROGMEM
|
||||
#define PGM_P const char *
|
||||
#define PSTR(text) (text)
|
||||
|
|
@ -20,4 +21,17 @@
|
|||
#define strncpy_P strncpy
|
||||
#endif
|
||||
|
||||
// 32 bits that cannot be predicted and differ after every reset
|
||||
inline uint32_t alexaRandom32()
|
||||
{
|
||||
#if defined(ESP8266)
|
||||
return RANDOM_REG32; // fed by the noise of the radio
|
||||
#elif defined(ESP32)
|
||||
return esp_random(); // ESP32: untested
|
||||
#else
|
||||
static std::random_device source;
|
||||
return source();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif // ALEXA_COMPAT_H
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ static const char ERROR_INVALID_DIRECTIVE[] PROGMEM = "INVALID_DIRECTIVE";
|
|||
static const char ERROR_NO_HANDLER[] PROGMEM = "The endpoint has no handler for directives";
|
||||
static const char ERROR_NO_CAPABILITY[] PROGMEM = "The endpoint does not have this capability";
|
||||
|
||||
static const char EVENT_ACTIVATION_STARTED[] PROGMEM = "ActivationStarted";
|
||||
static const char EVENT_DEACTIVATION_STARTED[] PROGMEM = "DeactivationStarted";
|
||||
static const char EVENT_DOORBELL_PRESS[] PROGMEM = "DoorbellPress";
|
||||
|
||||
AlexaDevice::AlexaDevice(const String& name, const String& rootTopic, const String& endpointId, AlexaTransport* transport)
|
||||
: name(name), endpointId(endpointId), rootTopic(rootTopic), transport(transport) {
|
||||
// Initialize event arrays to nullptr
|
||||
|
|
@ -260,8 +264,65 @@ void AlexaDevice::handleDirective(const JsonDocument& message) {
|
|||
buildStatusMessage(directive.correlationToken, true).asErrorResponse(ERROR_INVALID_DIRECTIVE, reason).send();
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDirective::deferred(unsigned int estimatedSeconds) const {
|
||||
AlexaStatusMessage message = device->buildStatusMessage(correlationToken, true);
|
||||
message.toDeferredResponse(estimatedSeconds);
|
||||
return message;
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDirective::error(AlexaErrorType type, const char* message) const {
|
||||
AlexaStatusMessage answer = device->buildStatusMessage(correlationToken, true);
|
||||
answer.asErrorResponse(type, message);
|
||||
return answer;
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDirective::sceneStarted() const {
|
||||
return device->sceneEvent(correlationToken, EVENT_ACTIVATION_STARTED);
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDirective::sceneStopped() const {
|
||||
return device->sceneEvent(correlationToken, EVENT_DEACTIVATION_STARTED);
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDevice::sceneEvent(const char* correlationToken, PGM_P name) {
|
||||
AlexaStatusMessage message = buildStatusMessage(correlationToken, true);
|
||||
message.toEvent(AlexaMessageKind::SCENE_EVENT, AlexaInterfaces::SceneController.ns, name);
|
||||
message.setCause(AlexaCause::VOICE_INTERACTION); // a directive comes from Alexa
|
||||
return message;
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDevice::changeReport(AlexaCause cause) {
|
||||
AlexaStatusMessage message = buildStatusMessage("", true);
|
||||
message.toChangeReport(cause);
|
||||
return message;
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDevice::doorbellPress(AlexaCause cause) {
|
||||
AlexaStatusMessage message = event(AlexaInterfaces::DoorbellEventSource, EVENT_DOORBELL_PRESS);
|
||||
message.setCause(cause);
|
||||
return message;
|
||||
}
|
||||
|
||||
AlexaStatusMessage AlexaDevice::event(const AlexaInterfaceDesc& row, const char* name) {
|
||||
bool announced = false;
|
||||
for (uint8_t i = 0; i < capabilityCount; ++i) {
|
||||
announced = announced || &capabilities[i]->getRow() == &row;
|
||||
}
|
||||
if (!announced) {
|
||||
ALEX2ESP_LOGE("%s: event of an interface the device does not have, Alexa will drop it: add the capability with addCapability()",
|
||||
endpointId.c_str());
|
||||
}
|
||||
AlexaStatusMessage message = buildStatusMessage("", true);
|
||||
message.toEvent(AlexaMessageKind::EVENT, row.ns, name);
|
||||
return message;
|
||||
}
|
||||
|
||||
AlexaSendResult AlexaDevice::publish(const char* topic, JsonDocument& doc) {
|
||||
// A change report or an event that the handler sends is not the answer to its directive: an answer repeats
|
||||
// the correlationToken
|
||||
if (!doc["event"]["header"]["correlationToken"].isNull()) {
|
||||
answered = true;
|
||||
}
|
||||
return transport->publish(topic, doc);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@ struct AlexaDirective {
|
|||
// device and send()
|
||||
AlexaStatusMessage response() const;
|
||||
AlexaStatusMessage stateReport() const;
|
||||
|
||||
// The answer to a directive that takes longer than the 7 s the backend waits, a lock that is still turning:
|
||||
// send() it, keep a copy of the correlationToken, and when the device is done send the answer itself with
|
||||
// device->response(token) ... sendAsync(). Alexa is told the estimate when it is not 0. Alexa accepts a
|
||||
// deferred answer for LockController and WakeOnLANController only.
|
||||
AlexaStatusMessage deferred(unsigned int estimatedSeconds = 0) const;
|
||||
|
||||
// The answer to a directive that was not carried out: send() it. The message is for the log of the skill.
|
||||
// What a type carries beside it goes into payload(), the validRange of VALUE_OUT_OF_RANGE.
|
||||
AlexaStatusMessage error(AlexaErrorType type, const char* message) const;
|
||||
|
||||
// What a scene answers Activate and Deactivate with, in place of a Response: send() it
|
||||
AlexaStatusMessage sceneStarted() const;
|
||||
AlexaStatusMessage sceneStopped() const;
|
||||
};
|
||||
|
||||
// One function can serve several devices: the directive says which one it is for
|
||||
|
|
@ -136,9 +150,32 @@ public:
|
|||
return AlexaStatusMessage(correlationToken,rootTopic,endpointId,isResponse,transport != nullptr ? this : nullptr);
|
||||
}
|
||||
|
||||
// The same by name, for the answer that is sent outside of the handler: after a DeferredResponse, with the
|
||||
// correlationToken the sketch has kept and sendAsync()
|
||||
AlexaStatusMessage response(const String& correlationToken) { return buildStatusMessage(correlationToken, true); }
|
||||
AlexaStatusMessage stateReport(const String& correlationToken) { return buildStatusMessage(correlationToken, false); }
|
||||
|
||||
// Tells Alexa that properties changed, whatever changed them; Alexa expects it within 3 s, also after a
|
||||
// directive, beside the Response. For the properties of a capability with setProactivelyReported(true):
|
||||
// device->changeReport(AlexaCause::PHYSICAL_INTERACTION).addPowerControllerProp(PowerController::ON)
|
||||
// .context().addHealthProp(EndpointHealth::OK).send()
|
||||
// The properties before context() are those that changed, at least one; the ones after it are the others.
|
||||
AlexaStatusMessage changeReport(AlexaCause cause);
|
||||
|
||||
// The button of a doorbell was pressed: send() it. The device has the capability DoorbellEventSource.
|
||||
AlexaStatusMessage doorbellPress(AlexaCause cause = AlexaCause::PHYSICAL_INTERACTION);
|
||||
|
||||
// An event of another interface, by the row of the interface and the name of the event. Its payload is
|
||||
// empty: what the page of the interface asks for goes into payload().
|
||||
AlexaStatusMessage event(const AlexaInterfaceDesc& row, const char* name);
|
||||
|
||||
private:
|
||||
friend class AlexaDeviceList;
|
||||
|
||||
friend struct AlexaDirective;
|
||||
|
||||
AlexaStatusMessage sceneEvent(const char* correlationToken, PGM_P name);
|
||||
|
||||
// AlexaTransport, for the messages of this device: notes the answer and passes it on to the bridge
|
||||
AlexaSendResult publish(const char* topic, JsonDocument& doc) override;
|
||||
void timestamp(char* buffer, size_t size) override;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,43 @@
|
|||
#include "AlexaStatusMessage.h"
|
||||
#include "AlexaBridgeLogic.h"
|
||||
#include "AlexaLog.h"
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
// What a 1.x sketch wrote where the time belongs when it built a property by hand: the backend's HTTP route
|
||||
// replaced it. Reports leave over MQTT now, where nothing rewrites them, so the library fills the time in.
|
||||
static const char TIME_PLACEHOLDER[] PROGMEM = "{REPLACE_WITH_DATETIME}";
|
||||
|
||||
static const char NS_THERMOSTAT_ERROR[] PROGMEM = "Alexa.ThermostatController";
|
||||
|
||||
// The names of the error types in the order of the enum: both are made of ALEXA_ERROR_TYPES. The table is linked
|
||||
// into a sketch that sends an error by its AlexaErrorType, about 1 KB of flash.
|
||||
#define ALEXA_ERROR_TEXT(name) static const char ERROR_TYPE_##name[] PROGMEM = #name;
|
||||
ALEXA_ERROR_TYPES(ALEXA_ERROR_TEXT)
|
||||
|
||||
#define ALEXA_ERROR_ENTRY(name) ERROR_TYPE_##name,
|
||||
static const char *const ERROR_TYPE_NAMES[] PROGMEM = {ALEXA_ERROR_TYPES(ALEXA_ERROR_ENTRY)};
|
||||
|
||||
static PGM_P causeName(AlexaCause cause)
|
||||
{
|
||||
switch (cause)
|
||||
{
|
||||
case AlexaCause::APP_INTERACTION:
|
||||
return PSTR("APP_INTERACTION");
|
||||
case AlexaCause::PERIODIC_POLL:
|
||||
return PSTR("PERIODIC_POLL");
|
||||
case AlexaCause::VOICE_INTERACTION:
|
||||
return PSTR("VOICE_INTERACTION");
|
||||
case AlexaCause::PHYSICAL_INTERACTION:
|
||||
break;
|
||||
}
|
||||
return PSTR("PHYSICAL_INTERACTION");
|
||||
}
|
||||
|
||||
AlexaStatusMessage::AlexaStatusMessage(const String &correlationToken, const String &rootTopic, const String &endpointId, const bool isResponse, AlexaTransport *transport)
|
||||
: rootTopic(rootTopic), endpointId(endpointId), transport(transport)
|
||||
: rootTopic(rootTopic),
|
||||
endpointId(endpointId),
|
||||
transport(transport),
|
||||
kind(isResponse ? AlexaMessageKind::RESPONSE : AlexaMessageKind::STATE_REPORT),
|
||||
changing(false)
|
||||
{
|
||||
JsonObject event = doc["event"].to<JsonObject>();
|
||||
|
||||
|
|
@ -23,20 +52,61 @@ AlexaStatusMessage::AlexaStatusMessage(const String &correlationToken, const Str
|
|||
event_header["name"] = "StateReport";
|
||||
}
|
||||
event_header["payloadVersion"] = "3";
|
||||
event_header["messageId"] = generateMessageId();
|
||||
|
||||
// Alexa takes a message with the id of an earlier one for its repeat. The id is made of the random source of
|
||||
// the hardware, which does not begin with the same numbers after every reset.
|
||||
const uint32_t random[4] = {alexaRandom32(), alexaRandom32(), alexaRandom32(), alexaRandom32()};
|
||||
char messageId[ALEXA_MESSAGE_ID_SIZE];
|
||||
AlexaBridgeLogic::formatMessageId(random, messageId, sizeof(messageId));
|
||||
event_header["messageId"] = messageId;
|
||||
event_header["correlationToken"] = correlationToken;
|
||||
|
||||
JsonObject event_endpoint = event["endpoint"].to<JsonObject>();
|
||||
event_endpoint["endpointId"] = endpointId;
|
||||
event["payload"].to<JsonObject>(); // required by Alexa.Response / StateReport, empty when there is nothing to add
|
||||
contextProperties = doc["context"]["properties"].to<JsonArray>();
|
||||
doc["context"]["properties"].to<JsonArray>();
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AlexaStatusMessage::AddContextProp(const JsonObject &property)
|
||||
JsonArray AlexaStatusMessage::properties()
|
||||
{
|
||||
if (contextProperties.add(property))
|
||||
if (changing)
|
||||
{
|
||||
JsonObject added = contextProperties[contextProperties.size() - 1];
|
||||
return doc["event"]["payload"]["change"]["properties"].as<JsonArray>();
|
||||
}
|
||||
// A kind without properties has no list: what is added to it goes nowhere
|
||||
return doc["context"]["properties"].as<JsonArray>();
|
||||
}
|
||||
|
||||
JsonObject AlexaStatusMessage::payload()
|
||||
{
|
||||
return doc["event"]["payload"].as<JsonObject>();
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AlexaStatusMessage::addTemperatureSensorProp(float value, TemperatureSensorScale scale, unsigned int uncertaintyInMs)
|
||||
{
|
||||
JsonDocument temperature;
|
||||
temperature["value"] = value;
|
||||
if (scale == TemperatureSensorScale::FAHRENHEIT)
|
||||
{
|
||||
temperature["scale"] = F("FAHRENHEIT");
|
||||
}
|
||||
else if (scale == TemperatureSensorScale::KELVIN)
|
||||
{
|
||||
temperature["scale"] = F("KELVIN");
|
||||
}
|
||||
else
|
||||
{
|
||||
temperature["scale"] = F("CELSIUS");
|
||||
}
|
||||
return AddProperty(AlexaInterfaces::TemperatureSensor, temperature.as<JsonObject>(), uncertaintyInMs);
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AlexaStatusMessage::addContextProp(JsonObjectConst property)
|
||||
{
|
||||
JsonArray list = properties();
|
||||
if (list.add(property))
|
||||
{
|
||||
JsonObject added = list[list.size() - 1];
|
||||
const char *timeOfSample = added["timeOfSample"] | "";
|
||||
if (timeOfSample[0] == '\0' || strcmp_P(timeOfSample, TIME_PLACEHOLDER) == 0)
|
||||
{
|
||||
|
|
@ -46,18 +116,109 @@ AlexaStatusMessage &AlexaStatusMessage::AddContextProp(const JsonObject &propert
|
|||
return *this; // a property that did not fit leaves the document marked as overflowed: send() refuses it
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AlexaStatusMessage::asErrorResponse(AlexaErrorType type, PGM_P message)
|
||||
{
|
||||
size_t place = static_cast<size_t>(type);
|
||||
if (place >= sizeof(ERROR_TYPE_NAMES) / sizeof(ERROR_TYPE_NAMES[0]))
|
||||
{
|
||||
place = static_cast<size_t>(AlexaErrorType::INTERNAL_ERROR);
|
||||
}
|
||||
asErrorResponse(static_cast<PGM_P>(pgm_read_ptr(&ERROR_TYPE_NAMES[place])), message);
|
||||
if (place >= static_cast<size_t>(AlexaErrorType::REQUESTED_SETPOINTS_TOO_CLOSE))
|
||||
{
|
||||
doc["event"]["header"]["namespace"] = FPSTR(NS_THERMOSTAT_ERROR);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AlexaStatusMessage::asErrorResponse(PGM_P type, PGM_P message)
|
||||
{
|
||||
kind = AlexaMessageKind::ERROR_RESPONSE;
|
||||
changing = false;
|
||||
JsonObject event = doc["event"];
|
||||
event["header"]["name"] = F("ErrorResponse");
|
||||
event["payload"][F("type")] = FPSTR(type);
|
||||
event["payload"][F("message")] = FPSTR(message);
|
||||
doc.remove("context");
|
||||
contextProperties = JsonArray(); // the array went with the context: what is added now goes nowhere
|
||||
return *this;
|
||||
}
|
||||
|
||||
void AlexaStatusMessage::toDeferredResponse(unsigned int estimatedSeconds)
|
||||
{
|
||||
kind = AlexaMessageKind::DEFERRED_RESPONSE;
|
||||
JsonObject event = doc["event"];
|
||||
event["header"]["name"] = F("DeferredResponse");
|
||||
if (estimatedSeconds > 0)
|
||||
{
|
||||
event["payload"][F("estimatedDeferralInSeconds")] = estimatedSeconds;
|
||||
}
|
||||
doc.remove("context");
|
||||
}
|
||||
|
||||
void AlexaStatusMessage::toChangeReport(AlexaCause cause)
|
||||
{
|
||||
kind = AlexaMessageKind::CHANGE_REPORT;
|
||||
changing = true;
|
||||
JsonObject event = doc["event"];
|
||||
JsonObject header = event["header"];
|
||||
header["name"] = F("ChangeReport");
|
||||
header.remove("correlationToken"); // no directive asked for the report
|
||||
JsonObject change = event["payload"][F("change")].to<JsonObject>();
|
||||
change[F("cause")][F("type")] = FPSTR(causeName(cause));
|
||||
change[F("properties")].to<JsonArray>();
|
||||
}
|
||||
|
||||
void AlexaStatusMessage::toEvent(AlexaMessageKind eventKind, PGM_P ns, PGM_P name)
|
||||
{
|
||||
kind = eventKind;
|
||||
JsonObject header = doc["event"]["header"];
|
||||
header["namespace"] = FPSTR(ns);
|
||||
header["name"] = FPSTR(name);
|
||||
if (kind == AlexaMessageKind::EVENT)
|
||||
{
|
||||
header.remove("correlationToken");
|
||||
}
|
||||
doc["context"].to<JsonObject>(); // the pages of the interfaces show their events with an empty context
|
||||
}
|
||||
|
||||
void AlexaStatusMessage::setCause(AlexaCause cause)
|
||||
{
|
||||
JsonObject event_payload = payload();
|
||||
event_payload[F("cause")][F("type")] = FPSTR(causeName(cause));
|
||||
|
||||
char now[ALEXA_TIMESTAMP_SIZE] = "";
|
||||
if (transport != nullptr)
|
||||
{
|
||||
transport->timestamp(now, sizeof(now));
|
||||
}
|
||||
event_payload[F("timestamp")] = now;
|
||||
}
|
||||
|
||||
bool AlexaStatusMessage::send()
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case AlexaMessageKind::CHANGE_REPORT:
|
||||
return publishOn(PSTR("/changeReport"), false);
|
||||
case AlexaMessageKind::EVENT:
|
||||
return publishOn(PSTR("/event"), false);
|
||||
default:
|
||||
return publishOn(PSTR("/alexaResponce"), true);
|
||||
}
|
||||
}
|
||||
|
||||
bool AlexaStatusMessage::sendAsync()
|
||||
{
|
||||
if (kind != AlexaMessageKind::RESPONSE && kind != AlexaMessageKind::STATE_REPORT && kind != AlexaMessageKind::ERROR_RESPONSE)
|
||||
{
|
||||
ALEX2ESP_LOGE("message for %s not sent: sendAsync() is for the Response, StateReport or ErrorResponse that follows a DeferredResponse, use send()", endpointId.c_str());
|
||||
doc.clear();
|
||||
return false;
|
||||
}
|
||||
return publishOn(PSTR("/deferredResponse"), true);
|
||||
}
|
||||
|
||||
bool AlexaStatusMessage::publishOn(PGM_P topicEnd, bool ofEndpoint)
|
||||
{
|
||||
if (transport == nullptr)
|
||||
{
|
||||
|
|
@ -65,32 +226,25 @@ bool AlexaStatusMessage::send()
|
|||
doc.clear();
|
||||
return false;
|
||||
}
|
||||
if (kind == AlexaMessageKind::CHANGE_REPORT && doc["event"]["payload"]["change"]["properties"].size() == 0)
|
||||
{
|
||||
ALEX2ESP_LOGE("change report for %s not sent: it has no property that changed, add one before context()", endpointId.c_str());
|
||||
doc.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
String topic = rootTopic + "/" + endpointId + "/alexaResponce";
|
||||
String topic = rootTopic;
|
||||
if (ofEndpoint)
|
||||
{
|
||||
topic += "/";
|
||||
topic += endpointId;
|
||||
}
|
||||
topic += FPSTR(topicEnd);
|
||||
AlexaSendResult result = transport->publish(topic.c_str(), doc);
|
||||
doc.clear();
|
||||
return result == AlexaSendResult::OK;
|
||||
}
|
||||
|
||||
// TODO: make real uuid4 gen function
|
||||
String AlexaStatusMessage::generateMessageId()
|
||||
{
|
||||
char buffer[38];
|
||||
const char charset[] = "0123456789abcdefABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; // Allowed characters
|
||||
|
||||
// Seed the random number generator (optional)
|
||||
srand(static_cast<unsigned int>(time(nullptr)));
|
||||
|
||||
// Generate 37 random characters
|
||||
for (int i = 0; i < 37; ++i)
|
||||
{
|
||||
buffer[i] = charset[rand() % (sizeof(charset) - 1)]; // Pick a random character
|
||||
}
|
||||
|
||||
buffer[37] = '\0'; // Null-terminate the string
|
||||
return String(buffer);
|
||||
}
|
||||
|
||||
void AlexaStatusMessage::setTimeOfSample(JsonObject property)
|
||||
{
|
||||
// A char array is copied into the document; the buffer does not have to outlive this call
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
// What a device tells Alexa: the answer to a directive, a change of its state, an event. One class builds them
|
||||
// all; the kind of a message decides what it carries and the topic it is published on.
|
||||
//
|
||||
// kind from topic
|
||||
// Response d.response(), device->response(token) <root>/<endpointId>/alexaResponce
|
||||
// StateReport d.stateReport() <root>/<endpointId>/alexaResponce
|
||||
// ErrorResponse d.error(type, message) <root>/<endpointId>/alexaResponce
|
||||
// DeferredResponse d.deferred(seconds) <root>/<endpointId>/alexaResponce
|
||||
// the answer after it device->response(token) ... sendAsync() <root>/<endpointId>/deferredResponse
|
||||
// scene event d.sceneStarted(), d.sceneStopped() <root>/<endpointId>/alexaResponce
|
||||
// ChangeReport device->changeReport(cause) <root>/changeReport
|
||||
// event device->doorbellPress(), device->event() <root>/event
|
||||
#ifndef ALEXA_STATUS_MESSAGE_H
|
||||
#define ALEXA_STATUS_MESSAGE_H
|
||||
|
||||
|
|
@ -20,83 +32,186 @@ enum class PowerController
|
|||
enum class TemperatureSensorScale
|
||||
{
|
||||
CELSIUS,
|
||||
FAHRENHEIT
|
||||
FAHRENHEIT,
|
||||
KELVIN
|
||||
};
|
||||
|
||||
enum class AlexaMessageKind : uint8_t
|
||||
{
|
||||
RESPONSE, // the answer to a directive that was carried out
|
||||
STATE_REPORT, // the answer to ReportState
|
||||
ERROR_RESPONSE, // the answer to a directive that was not carried out
|
||||
DEFERRED_RESPONSE, // the answer follows later, with sendAsync()
|
||||
SCENE_EVENT, // ActivationStarted or DeactivationStarted: what a scene answers with
|
||||
CHANGE_REPORT, // properties changed without a directive, or after one
|
||||
EVENT // what a device reports unasked, DoorbellPress
|
||||
};
|
||||
|
||||
// Why a property changed or an event happened (the cause object of alexa-changereport.html)
|
||||
enum class AlexaCause : uint8_t
|
||||
{
|
||||
APP_INTERACTION, // through the app of the device
|
||||
PERIODIC_POLL, // the device was asked for its state and it had changed
|
||||
PHYSICAL_INTERACTION, // somebody used the device itself
|
||||
VOICE_INTERACTION // through Alexa, by voice or in the Alexa app
|
||||
};
|
||||
|
||||
// The types of alexa-errorresponse.html, and after them those of Alexa.ThermostatController, which are sent in
|
||||
// the namespace of that interface (alexa-thermostatcontroller-errorresponse.html)
|
||||
#define ALEXA_ERROR_TYPES(X) \
|
||||
X(ALREADY_IN_OPERATION) X(BRIDGE_UNREACHABLE) X(CLOUD_CONTROL_DISABLED) X(DEVICE_STUCK) X(DO_NOT_DISTURB_MODE) \
|
||||
X(ENDPOINT_BUSY) X(ENDPOINT_CONTROL_UNAVAILABLE) X(ENDPOINT_LOW_POWER) X(ENDPOINT_UNREACHABLE) \
|
||||
X(EXPIRED_AUTHORIZATION_CREDENTIAL) X(FIRMWARE_OUT_OF_DATE) X(HARDWARE_MALFUNCTION) \
|
||||
X(INSUFFICIENT_PERMISSIONS) X(INSUFFICIENT_RESOURCE) X(INTERNAL_ERROR) X(INVALID_AUTHORIZATION_CREDENTIAL) \
|
||||
X(INVALID_DIRECTIVE) X(INVALID_VALUE) X(MAINTENANCE_REQUIRED) X(NO_SUCH_ENDPOINT) X(NOT_CALIBRATED) \
|
||||
X(NOT_IN_OPERATION) X(NOT_SUPPORTED_IN_CURRENT_MODE) X(NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE) \
|
||||
X(PARTNER_APPLICATION_REDIRECTION) X(POWER_LEVEL_NOT_SUPPORTED) X(RATE_LIMIT_EXCEEDED) \
|
||||
X(TEMPERATURE_VALUE_OUT_OF_RANGE) X(TOO_MANY_FAILED_ATTEMPTS) X(UNABLE_TO_CHARGE) X(VALUE_OUT_OF_RANGE) \
|
||||
X(REQUESTED_SETPOINTS_TOO_CLOSE) X(THERMOSTAT_IS_OFF) X(UNSUPPORTED_THERMOSTAT_MODE) \
|
||||
X(DUAL_SETPOINTS_UNSUPPORTED) X(TRIPLE_SETPOINTS_UNSUPPORTED) X(UNWILLING_TO_SET_SCHEDULE) \
|
||||
X(UNWILLING_TO_SET_VALUE)
|
||||
|
||||
enum class AlexaErrorType : uint8_t
|
||||
{
|
||||
#define ALEXA_ERROR_ENUMERATOR(name) name,
|
||||
ALEXA_ERROR_TYPES(ALEXA_ERROR_ENUMERATOR)
|
||||
#undef ALEXA_ERROR_ENUMERATOR
|
||||
};
|
||||
|
||||
class AlexaStatusMessage
|
||||
{
|
||||
public:
|
||||
// Built by AlexaDevice::buildStatusMessage(), which passes the bridge as the transport: it publishes the report
|
||||
// and supplies the time of every property. A message without a transport cannot be sent.
|
||||
// A Response (isResponse) or a StateReport. Built by AlexaDevice::buildStatusMessage(), which passes the
|
||||
// bridge as the transport: it publishes the message and supplies the time of every property. A message
|
||||
// without a transport cannot be sent.
|
||||
AlexaStatusMessage(const String &correlationToken, const String &rootTopic, const String &endpointId, const bool isResponse, AlexaTransport *transport = nullptr);
|
||||
|
||||
AlexaStatusMessage &AddHealthProp(EndpointHealth endpointHealth, unsigned int uncertaintyInMs = 0)
|
||||
AlexaMessageKind getKind() const { return kind; }
|
||||
|
||||
// The properties of the device. In a ChangeReport the properties added before context() are those that
|
||||
// changed and the ones after it the others; every other kind has one list. An ErrorResponse, a
|
||||
// DeferredResponse and an event carry no properties: what is added to them is not sent.
|
||||
AlexaStatusMessage &addHealthProp(EndpointHealth endpointHealth, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
JsonDocument healthValue;
|
||||
healthValue["value"] = (endpointHealth == EndpointHealth::OK) ? "OK" : "UNREACHABLE";
|
||||
return AddProperty(AlexaInterfaces::EndpointHealth, healthValue.as<JsonObject>(), uncertaintyInMs);
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AddPowerControllerProp(PowerController powerController, unsigned int uncertaintyInMs = 0)
|
||||
AlexaStatusMessage &addPowerControllerProp(PowerController powerController, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
String value = (powerController == PowerController::ON) ? "ON" : "OFF";
|
||||
return AddProperty(AlexaInterfaces::PowerController, value, uncertaintyInMs);
|
||||
}
|
||||
|
||||
AlexaStatusMessage &AddTemperatureSensorProp(TemperatureSensorScale tempSensor,float value, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
JsonDocument tempValue;
|
||||
tempValue["scale"] = "CELSIUS";
|
||||
tempValue["value"] = value;
|
||||
if((tempSensor == TemperatureSensorScale::FAHRENHEIT)){
|
||||
tempValue["value"] = (value - 32) * 5.0 / 9.0;
|
||||
}
|
||||
return AddProperty(AlexaInterfaces::TemperatureSensor, tempValue.as<JsonObject>(), uncertaintyInMs);
|
||||
}
|
||||
// The temperature is reported in the scale it is given in
|
||||
AlexaStatusMessage &addTemperatureSensorProp(float value, TemperatureSensorScale scale = TemperatureSensorScale::CELSIUS, unsigned int uncertaintyInMs = 0);
|
||||
|
||||
AlexaStatusMessage &AddBrightnessControllerProp(unsigned int brightness, unsigned int uncertaintyInMs = 0)
|
||||
AlexaStatusMessage &addBrightnessControllerProp(unsigned int brightness, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return AddProperty(AlexaInterfaces::BrightnessController, brightness, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddColorTemperatureControllerProp(unsigned int colorTemperature, unsigned int uncertaintyInMs = 0)
|
||||
|
||||
AlexaStatusMessage &addColorTemperatureControllerProp(unsigned int colorTemperature, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return AddProperty(AlexaInterfaces::ColorTemperatureController, colorTemperature, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddToggleControllerProp(PowerController powerController,String instanceName, unsigned int uncertaintyInMs = 0)
|
||||
|
||||
AlexaStatusMessage &addToggleControllerProp(const String &instance, PowerController toggleState, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
String value = (powerController == PowerController::ON) ? "ON" : "OFF";
|
||||
return AddProperty(AlexaInterfaces::ToggleController, value, uncertaintyInMs,instanceName);
|
||||
String value = (toggleState == PowerController::ON) ? "ON" : "OFF";
|
||||
return AddProperty(AlexaInterfaces::ToggleController, value, uncertaintyInMs, instance);
|
||||
}
|
||||
|
||||
// Adds a property the sketch built itself. Its timeOfSample is set to the current time when the object has
|
||||
// none or still carries the 1.x placeholder "{REPLACE_WITH_DATETIME}".
|
||||
AlexaStatusMessage &AddContextProp(const JsonObject &property);
|
||||
AlexaStatusMessage &addContextProp(JsonObjectConst property);
|
||||
|
||||
// The names of 1.x. Until 1.1.0 AddTemperatureSensorProp() converted Fahrenheit to Celsius.
|
||||
AlexaStatusMessage &AddHealthProp(EndpointHealth endpointHealth, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addHealthProp(endpointHealth, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddPowerControllerProp(PowerController powerController, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addPowerControllerProp(powerController, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddTemperatureSensorProp(TemperatureSensorScale tempSensor, float value, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addTemperatureSensorProp(value, tempSensor, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddBrightnessControllerProp(unsigned int brightness, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addBrightnessControllerProp(brightness, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddColorTemperatureControllerProp(unsigned int colorTemperature, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addColorTemperatureControllerProp(colorTemperature, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddToggleControllerProp(PowerController powerController, String instanceName, unsigned int uncertaintyInMs = 0)
|
||||
{
|
||||
return addToggleControllerProp(instanceName, powerController, uncertaintyInMs);
|
||||
}
|
||||
AlexaStatusMessage &AddContextProp(const JsonObject &property) { return addContextProp(property); }
|
||||
|
||||
// In a ChangeReport: the properties that changed have been added, the ones that follow are the others
|
||||
AlexaStatusMessage &context()
|
||||
{
|
||||
changing = false;
|
||||
return *this;
|
||||
}
|
||||
|
||||
// The payload of the event, for what a kind carries beside the properties:
|
||||
// d.error(AlexaErrorType::VALUE_OUT_OF_RANGE, "0 to 100").payload()["validRange"]["maximumValue"] = 100
|
||||
JsonObject payload();
|
||||
|
||||
// Turns the message into the ErrorResponse of alexa-errorresponse.html, which has no context: properties
|
||||
// added before or after are not sent. The type is one of that page, PSTR("INVALID_DIRECTIVE"); the message
|
||||
// is for the log of the skill, Alexa does not read it to the user. Both may be in program memory.
|
||||
// added before or after are not sent. The message is for the log of the skill, Alexa does not read it to the
|
||||
// user; it may be in program memory. A type of Alexa.ThermostatController is sent in that namespace.
|
||||
AlexaStatusMessage &asErrorResponse(AlexaErrorType type, PGM_P message);
|
||||
|
||||
// The same with the type as text, PSTR("INVALID_DIRECTIVE"), in the namespace Alexa
|
||||
AlexaStatusMessage &asErrorResponse(PGM_P type, PGM_P message);
|
||||
|
||||
// Publishes the report on <root>/<endpointId>/alexaResponce now. Returns false when nothing was sent: no
|
||||
// session with the broker, the MQTT client or the heap cannot take the report, or it is over
|
||||
// ALEX2ESP_MAX_MESSAGE bytes. The reason is on Serial; a report is never sent truncated.
|
||||
// Publishes the message now, on the topic of its kind. Returns false when nothing was sent: no session with
|
||||
// the broker, the MQTT client or the heap cannot take the message, it is over ALEX2ESP_MAX_MESSAGE bytes, or
|
||||
// it is a ChangeReport without a property that changed. The reason is on Serial; a message is never sent
|
||||
// truncated. A message is sent once: it is empty afterwards.
|
||||
bool send();
|
||||
|
||||
// Publishes the answer that follows a DeferredResponse, on <root>/<endpointId>/deferredResponse: a Response,
|
||||
// a StateReport or an ErrorResponse with the correlationToken of the directive, which the sketch has kept.
|
||||
// Returns false as send() does, and for any other kind.
|
||||
bool sendAsync();
|
||||
|
||||
private:
|
||||
friend class AlexaDevice;
|
||||
friend struct AlexaDirective;
|
||||
|
||||
String rootTopic;
|
||||
String endpointId;
|
||||
AlexaTransport *transport;
|
||||
JsonDocument doc;
|
||||
JsonArray contextProperties;
|
||||
AlexaMessageKind kind;
|
||||
bool changing; // a ChangeReport before context(): what is added has changed
|
||||
|
||||
String generateMessageId();
|
||||
// What the device and the directive make of a Response
|
||||
void toDeferredResponse(unsigned int estimatedSeconds);
|
||||
void toChangeReport(AlexaCause cause);
|
||||
void toEvent(AlexaMessageKind eventKind, PGM_P ns, PGM_P name);
|
||||
void setCause(AlexaCause cause); // with the time, as the payload of a scene and of a doorbell has it
|
||||
|
||||
// The list that takes the next property. Looked up for every property: a message is returned by value, and
|
||||
// a reference into the document that is kept in the message would not survive the move.
|
||||
JsonArray properties();
|
||||
void setTimeOfSample(JsonObject property);
|
||||
bool publishOn(PGM_P topicEnd, bool ofEndpoint);
|
||||
|
||||
// Adds the first property of the row: every interface with a helper above reports one
|
||||
template <typename T>
|
||||
AlexaStatusMessage &AddProperty(const AlexaInterfaceDesc &row, const T &value, unsigned int uncertaintyInMs = 0,const String &instanceName="")
|
||||
{
|
||||
JsonObject prop = contextProperties.add<JsonObject>();
|
||||
JsonObject prop = properties().add<JsonObject>();
|
||||
prop["namespace"] = FPSTR(row.ns);
|
||||
prop["name"] = FPSTR(row.property(0));
|
||||
|
||||
|
|
|
|||
|
|
@ -882,6 +882,33 @@ void test_wait_is_counted_across_the_overflow_of_millis()
|
|||
TEST_ASSERT_TRUE(backoff.due(744, since));
|
||||
}
|
||||
|
||||
// --- message id ---
|
||||
|
||||
void test_message_id_is_a_uuid_of_version_4()
|
||||
{
|
||||
const uint32_t random[4] = {0x01234567u, 0x89abcdefu, 0xfedcba98u, 0x76543210u};
|
||||
char id[ALEXA_MESSAGE_ID_SIZE];
|
||||
|
||||
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatMessageId(random, id, sizeof(id)));
|
||||
// The digits of the numbers in their order; the 13th is the version, the 17th has the variant in its two
|
||||
// highest bits (f, 1111, becomes b, 1011)
|
||||
TEST_ASSERT_EQUAL_STRING("01234567-89ab-4def-bedc-ba9876543210", id);
|
||||
|
||||
const uint32_t zeros[4] = {0, 0, 0, 0};
|
||||
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatMessageId(zeros, id, sizeof(id)));
|
||||
TEST_ASSERT_EQUAL_STRING("00000000-0000-4000-8000-000000000000", id);
|
||||
}
|
||||
|
||||
void test_message_id_needs_a_buffer_of_its_size()
|
||||
{
|
||||
const uint32_t random[4] = {1, 2, 3, 4};
|
||||
char id[ALEXA_MESSAGE_ID_SIZE] = "untouched";
|
||||
|
||||
TEST_ASSERT_FALSE(AlexaBridgeLogic::formatMessageId(random, id, ALEXA_MESSAGE_ID_SIZE - 1));
|
||||
TEST_ASSERT_EQUAL_STRING("", id);
|
||||
TEST_ASSERT_FALSE(AlexaBridgeLogic::formatMessageId(random, nullptr, ALEXA_MESSAGE_ID_SIZE));
|
||||
}
|
||||
|
||||
// --- time ---
|
||||
|
||||
void test_timestamp_is_iso_8601_in_utc()
|
||||
|
|
@ -984,6 +1011,8 @@ int main(int, char **)
|
|||
RUN_TEST(test_wait_keeps_doubling_across_sessions_that_end_early);
|
||||
RUN_TEST(test_attempt_that_fails_after_a_long_session_does_not_start_the_wait_over);
|
||||
RUN_TEST(test_length_of_a_session_is_counted_across_the_overflow_of_millis);
|
||||
RUN_TEST(test_message_id_is_a_uuid_of_version_4);
|
||||
RUN_TEST(test_message_id_needs_a_buffer_of_its_size);
|
||||
RUN_TEST(test_attempt_is_due_when_the_wait_is_over);
|
||||
RUN_TEST(test_wait_is_counted_across_the_overflow_of_millis);
|
||||
|
||||
|
|
|
|||
519
test/test_messages/test_main.cpp
Normal file
519
test/test_messages/test_main.cpp
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
// 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
|
||||
}
|
||||
|
||||
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);
|
||||
return UNITY_END();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue