From b9f4fd7f4b2b17be84a3cee3f38932173c6c2d15 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 5 May 2026 17:47:55 +1000 Subject: [PATCH] Per-entity availability, deferred discovery publish, suggested precision - Add HAAvailabilityConfig for per-entity availability_topic and payloads - Defer discovery until MQTT connected; publish availability after discovery - HASensor/HANumber: optional suggested_display_precision in discovery - HASerializer/HADictionary: extend for new keys; update device types and mocks - Docs and examples reflect availability and precision usage - Tests updated for discovery and availability behavior - Ignore .cursor/ in repo root --- .gitignore | 3 + README.md | 7 + docs/device-and-discovery.md | 22 ++ docs/mqtt-usage.md | 58 ++++ .../advanced-availability.ino | 9 + examples/button/button.ino | 2 + examples/led-switch/led-switch.ino | 5 + examples/number/number.ino | 3 + examples/sensor-analog/sensor-analog.ino | 2 + examples/sensor/sensor.ino | 4 + src/HADevice.cpp | 234 ++++++++++++- src/HADevice.h | 36 ++ src/HAMqtt.cpp | 275 ++++++++++++++- src/HAMqtt.h | 110 ++++++ src/device-types/HABaseDeviceType.cpp | 318 +++++++++++++++++- src/device-types/HABaseDeviceType.h | 66 ++++ src/device-types/HABinarySensor.cpp | 122 ++++++- src/device-types/HABinarySensor.h | 20 ++ src/device-types/HAButton.cpp | 46 ++- src/device-types/HAButton.h | 6 + src/device-types/HACamera.cpp | 6 +- src/device-types/HACover.cpp | 24 +- src/device-types/HADeviceTracker.cpp | 15 +- src/device-types/HAFan.cpp | 24 +- src/device-types/HAHVAC.cpp | 69 ++-- src/device-types/HALight.cpp | 42 +-- src/device-types/HALock.cpp | 15 +- src/device-types/HANumber.cpp | 108 ++++-- src/device-types/HANumber.h | 12 +- src/device-types/HAScene.cpp | 6 +- src/device-types/HASelect.cpp | 67 ++-- src/device-types/HASelect.h | 6 + src/device-types/HASensor.cpp | 166 ++++++++- src/device-types/HASensor.h | 22 ++ src/device-types/HASensorNumber.cpp | 9 +- src/device-types/HASwitch.cpp | 144 +++++++- src/device-types/HASwitch.h | 22 ++ src/device-types/HAText.cpp | 44 ++- src/device-types/HAText.h | 6 + src/mocks/PubSubClientMock.cpp | 12 +- src/mocks/PubSubClientMock.h | 11 +- src/utils/HAAvailabilityConfig.cpp | 194 +++++++++++ src/utils/HAAvailabilityConfig.h | 62 ++++ src/utils/HADictionary.cpp | 25 ++ src/utils/HADictionary.h | 26 ++ src/utils/HASerializer.cpp | 85 ++++- src/utils/HASerializer.h | 15 +- src/utils/HAUtils.cpp | 27 ++ src/utils/HAUtils.h | 6 + test/test_entities_basic/test_main.cpp | 6 +- test/test_entities_basic/test_main.h | 6 +- .../tests/test_binary_sensor.cpp | 10 + .../test_entities_basic/tests/test_sensor.cpp | 11 + .../test_entities_basic/tests/test_switch.cpp | 14 +- test/test_entities_basic/tests/test_text.cpp | 14 +- test/test_entities_numeric/test_main.cpp | 4 +- test/test_entities_numeric/test_main.h | 4 +- .../tests/test_number.cpp | 14 +- .../tests/test_select.cpp | 14 +- test/test_mqtt_core/test_main.cpp | 8 +- test/test_mqtt_core/test_main.h | 8 +- test/test_mqtt_core/tests/test_mqtt.cpp | 257 +++++++++++++- 62 files changed, 2705 insertions(+), 283 deletions(-) create mode 100644 src/utils/HAAvailabilityConfig.cpp create mode 100644 src/utils/HAAvailabilityConfig.h diff --git a/.gitignore b/.gitignore index b2a7614..329c13d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ tmp/ # Sphinx inventory (legacy); we ship Markdown-only under docs/ docs/*.inv + +# Cursor IDE (local rules, not part of the library) +.cursor/ diff --git a/README.md b/README.md index a05b33a..38c91bd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ lib_deps = * Two-way communication (state reporting and command execution) * MQTT discovery (device is added to the Home Assistant panel automatically) +* Rich discovery metadata for entities, devices, origin, templates and availability * MQTT Last Will and Testament * Support for custom MQTT messages (publishing and subscribing) * Auto reconnect with MQTT broker @@ -48,6 +49,12 @@ If you need to manage discovery at runtime: When device discovery mode is enabled, runtime discovery refreshes automatically clear any stale retained per-entity config before republishing the device discovery payload. +Recent discovery additions include: + +* Shared entity metadata such as `enabled_by_default`, `entity_picture`, `qos`, `encoding` +* Device/origin metadata such as `model_id`, `hw_version`, `serial_number`, `suggested_area`, `via_device`, `connections`, `support_url` +* Availability payload overrides and multi-topic availability discovery metadata + ## Supported HA types | Home Assistant type | Supported | diff --git a/docs/device-and-discovery.md b/docs/device-and-discovery.md index 3dba05f..d7e649a 100644 --- a/docs/device-and-discovery.md +++ b/docs/device-and-discovery.md @@ -13,6 +13,8 @@ Represents the physical board in Home Assistant: one device can expose multiple **Optional metadata** (each costs some RAM/flash; skip on tiny MCUs unless needed): - `setName`, `setSoftwareVersion`, `setManufacturer`, `setModel`, `setConfigurationUrl` +- `setModelId`, `setHardwareVersion`, `setSerialNumber`, `setSuggestedArea`, `setViaDevice` +- `addConnection("mac", "aa:bb:cc:dd:ee:ff")` or `setConnectionsJson("[[\"mac\",\"aa:bb:cc:dd:ee:ff\"]]")` String setters take **pointers whose contents are not copied** — use literals or storage that outlives the call. @@ -41,8 +43,28 @@ mqtt.setDataPrefix("myDataPrefix"); - **Default:** one retained discovery topic per entity (single-component discovery). - **Optional:** call `HAMqtt::enableDeviceDiscovery()` to publish a single **device** discovery payload with components under `cmps` (see project README for migration notes). +Device discovery can also publish richer origin/device metadata, for example: + +```cpp +device.setModelId("esp32-s3-devkit"); +device.setHardwareVersion("rev-b"); +device.setSerialNumber("SN-00042"); +device.setSuggestedArea("Garage"); +device.setViaDevice("main_gateway"); +device.addConnection("mac", "AA:BB:CC:DD:EE:FF"); +mqtt.setOriginSupportUrl("https://example.com/device-help"); +``` + For entity identifiers in Home Assistant, prefer **`setDefaultEntityId()`** over legacy **`setObjectId()`**. +Common entity discovery metadata can be configured on most entity types via: + +- `setEnabledByDefault(bool)` +- `setEntityPicture(const char*)` +- `setQos(uint8_t)` +- `setEncoding(const char*)` +- `setEntityCategory(const char*)` + ### Runtime discovery changes After changing discovery-related settings at runtime: diff --git a/docs/mqtt-usage.md b/docs/mqtt-usage.md index 6a741ce..5cb3555 100644 --- a/docs/mqtt-usage.md +++ b/docs/mqtt-usage.md @@ -46,12 +46,70 @@ mqtt.publish("customTopic", "payload", true); // retained ```cpp device.enableSharedAvailability(); +device.setPayloadAvailable("up"); +device.setPayloadNotAvailable("down"); device.enableLastWill(); // broker publishes offline when TCP drops // device.setAvailability(false); // optional: start as offline ``` **Per-entity availability:** call `someEntity.setAvailability(true/false)` on each type. Does not use LWT the same way as shared mode; see examples under `examples/availability/`. +Custom per-entity payloads are supported: + +```cpp +sensor.setPayloadAvailable("ready"); +sensor.setPayloadNotAvailable("lost"); +``` + +For multi-topic availability discovery, add full MQTT topics and a mode: + +```cpp +sensor.setAvailabilityMode("all"); +sensor.addAvailabilityEntry("bridge/status"); +sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}"); +``` + +## Discovery helpers by entity + +Common entity discovery metadata is available on most entity classes: + +```cpp +entity.setEnabledByDefault(false); +entity.setEntityPicture("https://example.com/entity.png"); +entity.setQos(1); +entity.setEncoding("utf-8"); +entity.setEntityCategory("diagnostic"); +``` + +Read-only sensor presentation/template helpers: + +```cpp +sensor.setSuggestedDisplayPrecision(2); +sensor.setValueTemplate("{{ value_json.temperature }}"); +sensor.setJsonAttributesTemplate("{{ value_json.attrs | tojson }}"); +sensor.setLastResetValueTemplate("{{ value_json.last_reset }}"); +sensor.setDeviceClass("enum"); +sensor.setOptions("idle;charging;discharging;fault"); +``` + +Writable entity template/payload helpers: + +```cpp +mySwitch.setPayloadOn("ENABLE"); +mySwitch.setPayloadOff("DISABLE"); +mySwitch.setStateOn("running"); +mySwitch.setStateOff("stopped"); +mySwitch.setValueTemplate("{{ value_json.state }}"); +mySwitch.setCommandTemplate("{{ value_json.command }}"); + +myNumber.setPayloadReset("RESET"); +myNumber.setCommandTemplate("{{ value | float | round(1) }}"); + +mySelect.setCommandTemplate("{{ value_json.choice }}"); +myText.setCommandTemplate("{{ value_json.text }}"); +myButton.setPayloadPress("PRESS"); +``` + ## Compiler macros Defined in `ArduinoHADefines.h` or via build flags. diff --git a/examples/advanced-availability/advanced-availability.ino b/examples/advanced-availability/advanced-availability.ino index 74017c8..99201db 100644 --- a/examples/advanced-availability/advanced-availability.ino +++ b/examples/advanced-availability/advanced-availability.ino @@ -29,10 +29,19 @@ void setup() { // set device's details (optional) device.setName("Arduino"); device.setSoftwareVersion("1.0.0"); + mqtt.setOriginSupportUrl("https://example.com/device-help"); sensor.setCurrentState(lastInputState); // optional sensor.setName("Door sensor"); // optional sensor.setDeviceClass("door"); // optional + sensor.setPayloadAvailable("ready"); + sensor.setPayloadNotAvailable("lost"); + + // Optional multi-topic discovery metadata for external health sources. + // Runtime availability publishing still uses sensor.setAvailability(...). + // sensor.setAvailabilityMode("all"); + // sensor.addAvailabilityEntry("bridge/status"); + // sensor.addAvailabilityEntry("sensor/health", "{{ value_json.state }}"); // This method enables availability for all device types registered on the device. // For example, if you have 5 sensors on the same device, you can enable diff --git a/examples/button/button.ino b/examples/button/button.ino index 4e9b323..462dbdd 100644 --- a/examples/button/button.ino +++ b/examples/button/button.ino @@ -33,6 +33,8 @@ void setup() { // optional properties buttonA.setIcon("mdi:fire"); buttonA.setName("Click me A"); + // buttonA.setPayloadPress("PRESS_A"); + // buttonA.setCommandTemplate("{{ value_json.command }}"); buttonB.setIcon("mdi:home"); buttonB.setName("Click me B"); diff --git a/examples/led-switch/led-switch.ino b/examples/led-switch/led-switch.ino index b48c724..dc9d3cd 100644 --- a/examples/led-switch/led-switch.ino +++ b/examples/led-switch/led-switch.ino @@ -33,6 +33,11 @@ void setup() { // set icon (optional) led.setIcon("mdi:lightbulb"); led.setName("My LED"); + // led.setPayloadOn("ENABLE"); + // led.setPayloadOff("DISABLE"); + // led.setStateOn("running"); + // led.setStateOff("stopped"); + // led.setCommandTemplate("{{ value_json.command }}"); // handle switch state led.onCommand(onSwitchCommand); diff --git a/examples/number/number.ino b/examples/number/number.ino index 7a014a5..015deae 100644 --- a/examples/number/number.ino +++ b/examples/number/number.ino @@ -52,6 +52,9 @@ void setup() { // number.setStep(0.5f); // minimum step: 0.001f // number.setMode(HANumber::ModeBox); // number.setMode(HANumber::ModeSlider); + // number.setValueTemplate("{{ value_json.level }}"); + // number.setPayloadReset("RESET"); + // number.setCommandTemplate("{{ value | float | round(1) }}"); // You can set retain flag for the HA commands // number.setRetain(true); diff --git a/examples/sensor-analog/sensor-analog.ino b/examples/sensor-analog/sensor-analog.ino index ca2c40a..77c9a1c 100644 --- a/examples/sensor-analog/sensor-analog.ino +++ b/examples/sensor-analog/sensor-analog.ino @@ -30,6 +30,8 @@ void setup() { analogSensor.setIcon("mdi:home"); analogSensor.setName("Analog voltage"); analogSensor.setUnitOfMeasurement("V"); + analogSensor.setSuggestedDisplayPrecision(2); + // analogSensor.setValueTemplate("{{ value_json.voltage }}"); mqtt.begin(BROKER_ADDR); } diff --git a/examples/sensor/sensor.ino b/examples/sensor/sensor.ino index 3980572..d86cb93 100644 --- a/examples/sensor/sensor.ino +++ b/examples/sensor/sensor.ino @@ -23,6 +23,10 @@ void setup() { // configure sensor (optional) valve.setIcon("mdi:home"); valve.setName("Water valve"); + // valve.setValueTemplate("{{ value_json.state }}"); + // valve.setDeviceClass("enum"); + // valve.setOptions("open;opening;closed"); + // valve.setDefaultEntityId("sensor.water_valve"); mqtt.begin(BROKER_ADDR); } diff --git a/src/HADevice.cpp b/src/HADevice.cpp index bdf4776..9fa8964 100644 --- a/src/HADevice.cpp +++ b/src/HADevice.cpp @@ -2,15 +2,58 @@ #include "HADevice.h" #include "HAMqtt.h" #include "utils/HAUtils.h" +#include "utils/HADictionary.h" #include "utils/HASerializer.h" +#include + +static bool appendEscapedJsonString(char*& cursor, char* end, const char* value) +{ + if (!cursor || !value || cursor >= end) { + return false; + } + + if (cursor + 1 >= end) { + return false; + } + + *cursor++ = '"'; + for (const char* p = value; *p != '\0'; p++) { + if ((*p == '"' || *p == '\\') && cursor + 2 >= end) { + return false; + } + + if (*p == '"' || *p == '\\') { + *cursor++ = '\\'; + } + + if (cursor + 1 >= end) { + return false; + } + + *cursor++ = *p; + } + + if (cursor + 1 >= end) { + return false; + } + + *cursor++ = '"'; + *cursor = 0; + return true; +} #define HADEVICE_INIT \ _ownsUniqueId(false), \ - _serializer(new HASerializer(nullptr, 6)), \ + _serializer(new HASerializer(nullptr, 16)), \ _availabilityTopic(nullptr), \ _sharedAvailability(false), \ _available(true), \ - _extendedUniqueIds(false) + _extendedUniqueIds(false), \ + _payloadAvailable(nullptr), \ + _payloadNotAvailable(nullptr), \ + _connectionsJson(), \ + _hasConnections(false), \ + _connectionsPropertyRegistered(false) HADevice::HADevice() : _uniqueId(nullptr), @@ -90,6 +133,153 @@ void HADevice::setConfigurationUrl(const char* url) ); } +void HADevice::setModelId(const char* modelId) +{ + if (!modelId) { + return; + } + + _serializer->set(AHATOFSTR(HADeviceModelIdProperty), modelId); +} + +void HADevice::setHardwareVersion(const char* hardwareVersion) +{ + if (!hardwareVersion) { + return; + } + + _serializer->set(AHATOFSTR(HADeviceHwVersionProperty), hardwareVersion); +} + +void HADevice::setSerialNumber(const char* serialNumber) +{ + if (!serialNumber) { + return; + } + + _serializer->set(AHATOFSTR(HADeviceSerialNumberProperty), serialNumber); +} + +void HADevice::setSuggestedArea(const char* suggestedArea) +{ + if (!suggestedArea) { + return; + } + + _serializer->set(AHATOFSTR(HADeviceSuggestedAreaProperty), suggestedArea); +} + +void HADevice::setViaDevice(const char* viaDevice) +{ + if (!viaDevice) { + return; + } + + _serializer->set(AHATOFSTR(HADeviceViaDeviceProperty), viaDevice); +} + +bool HADevice::addConnection(const char* type, const char* value) +{ + if (!type || !value || type[0] == '\0' || value[0] == '\0') { + return false; + } + + char next[MaxConnectionsJsonLength]; + if (_hasConnections) { + strncpy(next, _connectionsJson, MaxConnectionsJsonLength - 1); + next[MaxConnectionsJsonLength - 1] = 0; + } else { + strcpy(next, "[]"); + } + + const size_t len = strlen(next); + if (len < 2 || next[len - 1] != ']') { + return false; + } + + char* cursor = next + len - 1; + char* end = next + MaxConnectionsJsonLength - 1; + + if (cursor > next + 1) { + if (cursor + 1 >= end) { + return false; + } + *cursor++ = ','; + } + + if (cursor + 1 >= end) { + return false; + } + *cursor++ = '['; + *cursor = 0; + + if (!appendEscapedJsonString(cursor, end, type)) { + return false; + } + + if (cursor + 1 >= end) { + return false; + } + *cursor++ = ','; + *cursor = 0; + + if (!appendEscapedJsonString(cursor, end, value)) { + return false; + } + + if (cursor + 2 >= end) { + return false; + } + *cursor++ = ']'; + *cursor++ = ']'; + *cursor = 0; + + strncpy(_connectionsJson, next, MaxConnectionsJsonLength - 1); + _connectionsJson[MaxConnectionsJsonLength - 1] = 0; + _hasConnections = true; + + if (!_connectionsPropertyRegistered) { + _serializer->set( + AHATOFSTR(HADeviceConnectionsProperty), + _connectionsJson, + HASerializer::JsonLiteralPropertyValue + ); + _connectionsPropertyRegistered = true; + } + + return true; +} + +void HADevice::setConnectionsJson(const char* connectionsJson) +{ + if (!connectionsJson || connectionsJson[0] == '\0') { + return; + } + + strncpy(_connectionsJson, connectionsJson, MaxConnectionsJsonLength - 1); + _connectionsJson[MaxConnectionsJsonLength - 1] = 0; + _hasConnections = true; + + if (!_connectionsPropertyRegistered) { + _serializer->set( + AHATOFSTR(HADeviceConnectionsProperty), + _connectionsJson, + HASerializer::JsonLiteralPropertyValue + ); + _connectionsPropertyRegistered = true; + } +} + +void HADevice::setPayloadAvailable(const char* payload) +{ + _payloadAvailable = payload; +} + +void HADevice::setPayloadNotAvailable(const char* payload) +{ + _payloadNotAvailable = payload; +} + void HADevice::setAvailability(bool online) { _available = online; @@ -131,9 +321,13 @@ void HADevice::enableLastWill() return; } + const char* lw = (_payloadNotAvailable && _payloadNotAvailable[0] != '\0') + ? _payloadNotAvailable + : "offline"; + mqtt->setLastWill( _availabilityTopic, - "offline", + lw, true ); } @@ -145,11 +339,33 @@ void HADevice::publishAvailability() const return; } - const char* payload = _available ? HAOnline : HAOffline; - const uint16_t length = strlen_P(payload); - - if (mqtt->beginPublish(_availabilityTopic, length, true)) { - mqtt->writePayload(AHATOFSTR(payload)); - mqtt->endPublish(); + if (_available) { + if (_payloadAvailable && _payloadAvailable[0] != '\0') { + const uint16_t len = strlen(_payloadAvailable); + if (mqtt->beginPublish(_availabilityTopic, len, true)) { + mqtt->writePayload(_payloadAvailable, len); + mqtt->endPublish(); + } + } else { + const uint16_t len = strlen_P(HAOnline); + if (mqtt->beginPublish(_availabilityTopic, len, true)) { + mqtt->writePayload(AHATOFSTR(HAOnline)); + mqtt->endPublish(); + } + } + } else { + if (_payloadNotAvailable && _payloadNotAvailable[0] != '\0') { + const uint16_t len = strlen(_payloadNotAvailable); + if (mqtt->beginPublish(_availabilityTopic, len, true)) { + mqtt->writePayload(_payloadNotAvailable, len); + mqtt->endPublish(); + } + } else { + const uint16_t len = strlen_P(HAOffline); + if (mqtt->beginPublish(_availabilityTopic, len, true)) { + mqtt->writePayload(AHATOFSTR(HAOffline)); + mqtt->endPublish(); + } + } } } diff --git a/src/HADevice.h b/src/HADevice.h index 0949982..78295b6 100644 --- a/src/HADevice.h +++ b/src/HADevice.h @@ -131,6 +131,35 @@ public: */ void setConfigurationUrl(const char* url); + void setModelId(const char* modelId); + void setHardwareVersion(const char* hardwareVersion); + void setSerialNumber(const char* serialNumber); + void setSuggestedArea(const char* suggestedArea); + void setViaDevice(const char* viaDevice); + + /** + * Adds one MQTT device connection entry (e.g. `("mac", "aa:bb:cc:dd:ee:ff")`). + * Connections are serialized as JSON arrays inside the discovery `dev` object. + * + * @returns Returns `false` when the internal JSON buffer would overflow. + */ + bool addConnection(const char* type, const char* value); + + /** + * Sets the `connections` array as raw JSON (e.g. [[\"mac\",\"aa:bb:cc:dd:ee:ff\"]]). + * The payload is copied into an internal buffer. + */ + void setConnectionsJson(const char* connectionsJson); + + void setPayloadAvailable(const char* payload); + void setPayloadNotAvailable(const char* payload); + + inline const char* getPayloadAvailable() const + { return _payloadAvailable; } + + inline const char* getPayloadNotAvailable() const + { return _payloadNotAvailable; } + /** * Sets device's availability and publishes MQTT message on the availability topic. * If the device is not connected to an MQTT broker or the shared availability is not enabled then nothing happens. @@ -178,6 +207,13 @@ private: /// Specifies whether extended unique IDs feature is enabled. bool _extendedUniqueIds; + + const char* _payloadAvailable; + const char* _payloadNotAvailable; + static const uint16_t MaxConnectionsJsonLength = 192; + char _connectionsJson[MaxConnectionsJsonLength]; + bool _hasConnections; + bool _connectionsPropertyRegistered; }; #endif diff --git a/src/HAMqtt.cpp b/src/HAMqtt.cpp index 55f9ca6..d05836b 100644 --- a/src/HAMqtt.cpp +++ b/src/HAMqtt.cpp @@ -1,6 +1,7 @@ #include "HAMqtt.h" #include +#include #ifndef ARDUINOHA_TEST #include @@ -22,6 +23,7 @@ _discoveryPrefix(DefaultDiscoveryPrefix), \ _dataPrefix(DefaultDataPrefix), \ _deviceDiscoveryEnabled(false), \ + _originSupportUrl(nullptr), \ _username(nullptr), \ _password(nullptr), \ _lastConnectionAttemptAt(0), \ @@ -32,7 +34,12 @@ _lastWillTopic(nullptr), \ _lastWillMessage(nullptr), \ _lastWillRetain(false), \ - _currentState(StateDisconnected) + _currentState(StateDisconnected), \ + _messageDispatchDepth(0), \ + _deferredQueue{}, \ + _deferredHead(0), \ + _deferredCount(0), \ + _deferredBuilder() static const char* DefaultDiscoveryPrefix = "homeassistant"; static const char* DefaultDataPrefix = "aha"; @@ -76,6 +83,8 @@ HAMqtt::HAMqtt( HAMqtt::~HAMqtt() { + clearDeferredBuilder(); + clearDeferredQueue(); delete[] _devicesTypes; #ifdef ARDUINOHA_TEST @@ -177,6 +186,7 @@ bool HAMqtt::disconnect() ARDUINOHA_DEBUG_PRINTLN(F("AHA: disconnecting")) + clearDeferredBuilder(); _initialized = false; _lastConnectionAttemptAt = 0; _mqtt->disconnect(); @@ -198,6 +208,10 @@ void HAMqtt::loop() if (!result) { connectToServer(); } + + if (_messageDispatchDepth == 0 && isConnected()) { + flushDeferredPublishes(); + } } bool HAMqtt::isConnected() const @@ -233,17 +247,33 @@ void HAMqtt::addDeviceType(HABaseDeviceType* deviceType) bool HAMqtt::publish(const char* topic, const char* payload, bool retained) { - if (!isConnected()) { + if (!isConnected() || !topic || !payload) { return false; } + const size_t len = strlen(payload); + if (len > UINT16_MAX) { + return false; + } + + const uint16_t payloadLength = static_cast(len); + ARDUINOHA_DEBUG_PRINT(F("AHA: publishing ")) ARDUINOHA_DEBUG_PRINT(topic) ARDUINOHA_DEBUG_PRINT(F(", len: ")) - ARDUINOHA_DEBUG_PRINTLN(strlen(payload)) + ARDUINOHA_DEBUG_PRINTLN(payloadLength) - _mqtt->beginPublish(topic, strlen(payload), retained); - _mqtt->write((const uint8_t*)(payload), strlen(payload)); + if (isProcessingMessage()) { + return enqueueDeferredPublish( + topic, + reinterpret_cast(payload), + payloadLength, + retained + ); + } + + _mqtt->beginPublish(topic, payloadLength, retained); + _mqtt->write(reinterpret_cast(payload), payloadLength); return _mqtt->endPublish(); } @@ -258,7 +288,29 @@ bool HAMqtt::beginPublish( ARDUINOHA_DEBUG_PRINT(F(", len: ")) ARDUINOHA_DEBUG_PRINTLN(payloadLength) - return _mqtt->beginPublish(topic, payloadLength, retained); + if (!isConnected() || !topic) { + return false; + } + + if (!isProcessingMessage()) { + return _mqtt->beginPublish(topic, payloadLength, retained); + } + + if (_deferredBuilder.active) { + return false; + } + + const size_t topicLen = strlen(topic); + + _deferredBuilder.topic = new char[topicLen + 1]; + memcpy(_deferredBuilder.topic, topic, topicLen + 1); + _deferredBuilder.payload = payloadLength > 0 ? new uint8_t[payloadLength] : nullptr; + _deferredBuilder.expectedLength = payloadLength; + _deferredBuilder.writtenLength = 0; + _deferredBuilder.retained = retained; + _deferredBuilder.active = true; + _deferredBuilder.valid = true; + return true; } void HAMqtt::writePayload(const char* data, const uint16_t length) @@ -268,17 +320,71 @@ void HAMqtt::writePayload(const char* data, const uint16_t length) void HAMqtt::writePayload(const uint8_t* data, const uint16_t length) { + if (isProcessingMessage() && _deferredBuilder.active) { + if (!_deferredBuilder.valid || + (static_cast(_deferredBuilder.writtenLength) + length) > _deferredBuilder.expectedLength) { + _deferredBuilder.valid = false; + return; + } + + if (length > 0) { + memcpy( + _deferredBuilder.payload + _deferredBuilder.writtenLength, + data, + length + ); + } + + _deferredBuilder.writtenLength = static_cast(_deferredBuilder.writtenLength + length); + return; + } + _mqtt->write(data, length); } void HAMqtt::writePayload(const __FlashStringHelper* src) { + if (isProcessingMessage() && _deferredBuilder.active) { + PGM_P p = reinterpret_cast(src); + const uint16_t chunkLen = static_cast(strlen_P(p)); + if (!_deferredBuilder.valid || + (static_cast(_deferredBuilder.writtenLength) + chunkLen) > _deferredBuilder.expectedLength) { + _deferredBuilder.valid = false; + return; + } + + if (chunkLen > 0) { + memcpy_P(_deferredBuilder.payload + _deferredBuilder.writtenLength, p, chunkLen); + _deferredBuilder.writtenLength = static_cast(_deferredBuilder.writtenLength + chunkLen); + } + + return; + } + _mqtt->print(src); } bool HAMqtt::endPublish() { - return _mqtt->endPublish(); + if (!isProcessingMessage()) { + return _mqtt->endPublish(); + } + + if (!_deferredBuilder.active || + !_deferredBuilder.valid || + _deferredBuilder.writtenLength != _deferredBuilder.expectedLength) { + clearDeferredBuilder(); + return false; + } + + const bool ok = enqueueDeferredPublish( + _deferredBuilder.topic, + _deferredBuilder.payload, + _deferredBuilder.expectedLength, + _deferredBuilder.retained + ); + clearDeferredBuilder(); + return ok; } bool HAMqtt::subscribe(const char* topic) @@ -296,6 +402,8 @@ void HAMqtt::processMessage(const char* topic, const uint8_t* payload, uint16_t ARDUINOHA_DEBUG_PRINT(F(", len: ")) ARDUINOHA_DEBUG_PRINTLN(length) + _messageDispatchDepth++; + if (_messageCallback) { _messageCallback(topic, payload, length); } @@ -303,6 +411,12 @@ void HAMqtt::processMessage(const char* topic, const uint8_t* payload, uint16_t for (uint8_t i = 0; i < _devicesTypesNb; i++) { _devicesTypes[i]->onMqttMessage(topic, payload, length); } + + _messageDispatchDepth--; + + if (_messageDispatchDepth == 0) { + flushDeferredPublishes(); + } } void HAMqtt::connectToServer() @@ -398,15 +512,26 @@ bool HAMqtt::publishDeviceDiscovery() return false; } - char originPayload[64]; + char originPayload[192]; originPayload[0] = 0; - snprintf( - originPayload, - sizeof(originPayload), - "{\"name\":\"%s\",\"sw\":\"%s\"}", - DeviceDiscoveryOriginName, - ARDUINOHA_LIBRARY_VERSION - ); + if (_originSupportUrl && _originSupportUrl[0] != '\0') { + snprintf( + originPayload, + sizeof(originPayload), + "{\"name\":\"%s\",\"sw\":\"%s\",\"url\":\"%s\"}", + DeviceDiscoveryOriginName, + ARDUINOHA_LIBRARY_VERSION, + _originSupportUrl + ); + } else { + snprintf( + originPayload, + sizeof(originPayload), + "{\"name\":\"%s\",\"sw\":\"%s\"}", + DeviceDiscoveryOriginName, + ARDUINOHA_LIBRARY_VERSION + ); + } const uint16_t originPayloadLength = strlen(originPayload); const uint16_t topicLength = @@ -517,4 +642,124 @@ void HAMqtt::setState(ConnectionState state) if (_stateChangedCallback) { _stateChangedCallback(_currentState); } +} + +bool HAMqtt::enqueueDeferredPublish( + const char* topic, + const uint8_t* payload, + uint16_t length, + bool retained +) +{ + if (!topic || _deferredCount >= DeferredQueueCapacity) { + return false; + } + + if (length > 0 && payload == nullptr) { + return false; + } + + const uint8_t slot = static_cast((_deferredHead + _deferredCount) % DeferredQueueCapacity); + DeferredPublishMessage& msg = _deferredQueue[slot]; + + const size_t topicLen = strlen(topic); + msg.topic = new char[topicLen + 1]; + memcpy(msg.topic, topic, topicLen + 1); + + if (length > 0) { + msg.payload = new uint8_t[length]; + memcpy(msg.payload, payload, length); + } else { + msg.payload = nullptr; + } + + msg.length = length; + msg.retained = retained; + _deferredCount++; + +#ifdef ARDUINOHA_TEST + _deferredPublishEnqueueCountForTest++; +#endif + + return true; +} + +void HAMqtt::clearDeferredMessage(DeferredPublishMessage& msg) +{ + delete[] msg.topic; + delete[] msg.payload; + msg.topic = nullptr; + msg.payload = nullptr; + msg.length = 0; + msg.retained = false; +} + +void HAMqtt::clearDeferredQueue() +{ + while (_deferredCount > 0) { + DeferredPublishMessage& msg = _deferredQueue[_deferredHead]; + clearDeferredMessage(msg); + _deferredHead = static_cast((_deferredHead + 1) % DeferredQueueCapacity); + _deferredCount--; + } + + _deferredHead = 0; +} + +void HAMqtt::clearDeferredBuilder() +{ + delete[] _deferredBuilder.topic; + delete[] _deferredBuilder.payload; + _deferredBuilder.topic = nullptr; + _deferredBuilder.payload = nullptr; + _deferredBuilder.expectedLength = 0; + _deferredBuilder.writtenLength = 0; + _deferredBuilder.retained = false; + _deferredBuilder.active = false; + _deferredBuilder.valid = false; +} + +bool HAMqtt::flushDeferredPublishes() +{ + while (_deferredCount > 0) { + DeferredPublishMessage& msg = _deferredQueue[_deferredHead]; + + if (!isConnected()) { +#ifdef ARDUINOHA_TEST + _deferredFlushFailedForTest = true; + _lastDeferredFlushErrorForTest = DeferredFlushErrorNotConnected; +#endif + return false; + } + + if (!_mqtt->beginPublish(msg.topic, msg.length, msg.retained)) { +#ifdef ARDUINOHA_TEST + _deferredFlushFailedForTest = true; + _lastDeferredFlushErrorForTest = DeferredFlushErrorBeginPublish; +#endif + return false; + } + + if (msg.length > 0 && msg.payload != nullptr) { + _mqtt->write(msg.payload, msg.length); + } + + if (!_mqtt->endPublish()) { +#ifdef ARDUINOHA_TEST + _deferredFlushFailedForTest = true; + _lastDeferredFlushErrorForTest = DeferredFlushErrorEndPublish; +#endif + return false; + } + + clearDeferredMessage(msg); + _deferredHead = static_cast((_deferredHead + 1) % DeferredQueueCapacity); + _deferredCount--; + } + +#ifdef ARDUINOHA_TEST + _deferredFlushFailedForTest = false; + _lastDeferredFlushErrorForTest = DeferredFlushErrorNone; +#endif + return true; } \ No newline at end of file diff --git a/src/HAMqtt.h b/src/HAMqtt.h index f1dfd21..6c27171 100644 --- a/src/HAMqtt.h +++ b/src/HAMqtt.h @@ -136,6 +136,15 @@ public: */ bool publishDeviceDiscovery(); + /** + * Sets optional `support_url` in the discovery `origin` object (must remain valid). + */ + inline void setOriginSupportUrl(const char* url) + { _originSupportUrl = url; } + + inline const char* getOriginSupportUrl() const + { return _originSupportUrl; } + /** * Returns instance of the device assigned to the HAMqtt class. * It's the same object (pointer) that was passed to the HAMqtt constructor. @@ -310,6 +319,10 @@ public: * Message won't be published if the connection with the MQTT broker is not established. * In this case method returns false. * + * While handling an inbound MQTT message (see isProcessingMessage()), a successful + * return means the message was queued for send after dispatch completes, not that it + * was already transmitted. Outside that context, behavior is unchanged. + * * @param topic The topic to publish. * @param payload The payload to publish (it may be empty const char). * @param retained Specifies whether message should be retained. @@ -402,12 +415,47 @@ public: */ void processMessage(const char* topic, const uint8_t* payload, uint16_t length); + /** + * True while handling an inbound MQTT message (user onMessage and device onMqttMessage). + * Publish attempts in this window are queued and flushed when dispatch completes. + */ + inline bool isProcessingMessage() const + { return _messageDispatchDepth > 0; } + #ifdef ARDUINOHA_TEST inline uint8_t getDevicesTypesNb() const { return _devicesTypesNb; } inline HABaseDeviceType** getDevicesTypes() const { return _devicesTypes; } + + inline uint16_t getDeferredPublishEnqueueCountForTest() const + { return _deferredPublishEnqueueCountForTest; } + + inline void resetDeferredPublishTestCounters() + { + _deferredPublishEnqueueCountForTest = 0; + _deferredFlushFailedForTest = false; + _lastDeferredFlushErrorForTest = 0; + } + + inline uint8_t getPendingDeferredPublishesForTest() const + { return _deferredCount; } + + inline bool hasDeferredFlushFailureForTest() const + { return _deferredFlushFailedForTest; } + + inline uint8_t getLastDeferredFlushErrorForTest() const + { return _lastDeferredFlushErrorForTest; } + + inline bool didDeferredFlushFailDueToDisconnectForTest() const + { return _lastDeferredFlushErrorForTest == 1; } + + inline bool didDeferredFlushFailAtBeginPublishForTest() const + { return _lastDeferredFlushErrorForTest == 2; } + + inline bool didDeferredFlushFailAtEndPublishForTest() const + { return _lastDeferredFlushErrorForTest == 3; } #endif private: @@ -433,6 +481,51 @@ private: */ void setState(ConnectionState state); + struct DeferredPublishMessage { + char* topic; + uint8_t* payload; + uint16_t length; + bool retained; + }; + + struct DeferredPublishBuilder { + bool active; + bool valid; + char* topic; + uint8_t* payload; + uint16_t expectedLength; + uint16_t writtenLength; + bool retained; + }; + + enum DeferredFlushError { + DeferredFlushErrorNone = 0, + DeferredFlushErrorNotConnected, + DeferredFlushErrorBeginPublish, + DeferredFlushErrorEndPublish + }; + + static const uint8_t DeferredQueueCapacity = 8; + + bool enqueueDeferredPublish( + const char* topic, + const uint8_t* payload, + uint16_t length, + bool retained + ); + + void clearDeferredMessage(DeferredPublishMessage& msg); + + void clearDeferredQueue(); + + void clearDeferredBuilder(); + + /** + * Sends queued publishes in order. On transport failure, stops and leaves the + * remaining queue intact for a later retry (e.g. from loop()). + */ + bool flushDeferredPublishes(); + #ifdef ARDUINOHA_TEST PubSubClientMock* _mqtt; #else @@ -470,6 +563,8 @@ private: /// Enables MQTT device discovery mode when set to true. bool _deviceDiscoveryEnabled; + const char* _originSupportUrl; + /// The username used for the authentication. It's set in the HAMqtt::begin method. const char* _username; @@ -502,6 +597,21 @@ private: /// The last known state of the MQTT connection. ConnectionState _currentState; + + /// Nesting depth for inbound message dispatch (processMessage). + uint8_t _messageDispatchDepth; + + DeferredPublishMessage _deferredQueue[DeferredQueueCapacity]; + uint8_t _deferredHead; + uint8_t _deferredCount; + + DeferredPublishBuilder _deferredBuilder; + +#ifdef ARDUINOHA_TEST + uint16_t _deferredPublishEnqueueCountForTest = 0; + bool _deferredFlushFailedForTest = false; + uint8_t _lastDeferredFlushErrorForTest = 0; +#endif }; #endif diff --git a/src/device-types/HABaseDeviceType.cpp b/src/device-types/HABaseDeviceType.cpp index e030db6..b0e1246 100644 --- a/src/device-types/HABaseDeviceType.cpp +++ b/src/device-types/HABaseDeviceType.cpp @@ -2,7 +2,9 @@ #include "../HAMqtt.h" #include "../HADevice.h" #include "../utils/HAUtils.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" +#include HABaseDeviceType::HABaseDeviceType( const __FlashStringHelper* componentName, @@ -15,6 +17,16 @@ HABaseDeviceType::HABaseDeviceType( _defaultEntityId(nullptr), _entityCategory(nullptr), _serializer(nullptr), + _hasEnabledByDefault(false), + _enabledByDefault(true), + _entityPicture(nullptr), + _hasQos(false), + _qosNumeric(), + _encoding(nullptr), + _payloadAvailable(nullptr), + _payloadNotAvailable(nullptr), + _availabilityMode(nullptr), + _availabilityList(), _availability(AvailabilityDefault) { if (mqtt()) { @@ -166,13 +178,86 @@ void HABaseDeviceType::publishAvailability() return; } - publishOnDataTopic( - AHATOFSTR(HAAvailabilityTopic), - _availability == AvailabilityOnline + const bool online = (_availability == AvailabilityOnline); + + if (_availabilityList.count() > 0) { + char defaultBuf[12]; + const __FlashStringHelper* flashPayload = online ? AHATOFSTR(HAOnline) - : AHATOFSTR(HAOffline), - true - ); + : AHATOFSTR(HAOffline); + + for (uint8_t i = 0; i < _availabilityList.count(); i++) { + const HAAvailabilityConfig::Entry& e = _availabilityList.getEntry(i); + const char* payload = nullptr; + + if (online) { + if (e.payloadAvailable && e.payloadAvailable[0] != '\0') { + payload = e.payloadAvailable; + } else if (_payloadAvailable && _payloadAvailable[0] != '\0') { + payload = _payloadAvailable; + } else { + strncpy_P( + defaultBuf, + reinterpret_cast(AHAFROMFSTR(flashPayload)), + sizeof(defaultBuf) - 1 + ); + defaultBuf[sizeof(defaultBuf) - 1] = 0; + payload = defaultBuf; + } + } else { + if (e.payloadNotAvailable && e.payloadNotAvailable[0] != '\0') { + payload = e.payloadNotAvailable; + } else if (_payloadNotAvailable && _payloadNotAvailable[0] != '\0') { + payload = _payloadNotAvailable; + } else { + strncpy_P( + defaultBuf, + reinterpret_cast(AHAFROMFSTR(flashPayload)), + sizeof(defaultBuf) - 1 + ); + defaultBuf[sizeof(defaultBuf) - 1] = 0; + payload = defaultBuf; + } + } + + publishAbsolute(e.topic, payload, true); + } + + return; + } + + const char* ramPayload = online + ? effectivePayloadAvailable() + : effectivePayloadNotAvailable(); + + if (ramPayload) { + publishOnDataTopic(AHATOFSTR(HAAvailabilityTopic), ramPayload, true); + } else { + publishOnDataTopic( + AHATOFSTR(HAAvailabilityTopic), + online ? AHATOFSTR(HAOnline) : AHATOFSTR(HAOffline), + true + ); + } +} + +bool HABaseDeviceType::publishAbsolute( + const char* fullTopic, + const char* payload, + bool retained +) +{ + if (!fullTopic || !payload || !mqtt()) { + return false; + } + + const uint16_t len = strlen(payload); + if (!mqtt()->beginPublish(fullTopic, len, retained)) { + return false; + } + + mqtt()->writePayload(payload, len); + return mqtt()->endPublish(); } bool HABaseDeviceType::publishOnDataTopic( @@ -290,4 +375,225 @@ HASerializer* HABaseDeviceType::buildDeviceDiscoverySerializer() bool HABaseDeviceType::supportsDeviceDiscovery() const { return false; +} + +void HABaseDeviceType::setEnabledByDefault(bool enabled) +{ + _enabledByDefault = enabled; + _hasEnabledByDefault = true; +} + +void HABaseDeviceType::clearEnabledByDefault() +{ + _hasEnabledByDefault = false; +} + +void HABaseDeviceType::setEntityPicture(const char* url) +{ + _entityPicture = url; +} + +void HABaseDeviceType::clearEntityPicture() +{ + _entityPicture = nullptr; +} + +void HABaseDeviceType::setQos(uint8_t qos) +{ + if (qos > 2) { + qos = 2; + } + + _qosNumeric = HANumeric(static_cast(qos), 0); + _hasQos = true; +} + +void HABaseDeviceType::clearQos() +{ + _hasQos = false; + _qosNumeric.reset(); +} + +void HABaseDeviceType::setEncoding(const char* encoding) +{ + _encoding = encoding; +} + +void HABaseDeviceType::clearEncoding() +{ + _encoding = nullptr; +} + +void HABaseDeviceType::setPayloadAvailable(const char* payload) +{ + _payloadAvailable = payload; +} + +void HABaseDeviceType::setPayloadNotAvailable(const char* payload) +{ + _payloadNotAvailable = payload; +} + +void HABaseDeviceType::setAvailabilityMode(const char* mode) +{ + _availabilityMode = mode; +} + +bool HABaseDeviceType::addAvailabilityEntry( + const char* topic, + const char* valueTemplate, + const char* payloadAvailable, + const char* payloadNotAvailable +) +{ + return _availabilityList.add( + topic, + valueTemplate, + payloadAvailable, + payloadNotAvailable + ); +} + +void HABaseDeviceType::clearAvailabilityEntries() +{ + _availabilityList.clear(); +} + +void HABaseDeviceType::applyCommonEntityProperties( + HASerializer* serializer, + bool includeEncoding +) const +{ + if (!serializer) { + return; + } + + if (_hasEnabledByDefault) { + serializer->set( + AHATOFSTR(HAEnabledByDefaultProperty), + &_enabledByDefault, + HASerializer::BoolPropertyType + ); + } + + if (nonEmptyString(_entityPicture)) { + serializer->set(AHATOFSTR(HAEntityPictureProperty), _entityPicture); + } + + if (_hasQos) { + serializer->set( + AHATOFSTR(HAQosProperty), + &_qosNumeric, + HASerializer::NumberPropertyType + ); + } + + if (includeEncoding && nonEmptyString(_encoding)) { + serializer->set(AHATOFSTR(HAEncodingProperty), _encoding); + } +} + +const char* HABaseDeviceType::effectivePayloadAvailable() const +{ + if (_payloadAvailable && _payloadAvailable[0] != '\0') { + return _payloadAvailable; + } + + HAMqtt* m = mqtt(); + if (!m) { + return nullptr; + } + + const HADevice* d = m->getDevice(); + if (d && d->isSharedAvailabilityEnabled()) { + return d->getPayloadAvailable(); + } + + return nullptr; +} + +const char* HABaseDeviceType::effectivePayloadNotAvailable() const +{ + if (_payloadNotAvailable && _payloadNotAvailable[0] != '\0') { + return _payloadNotAvailable; + } + + HAMqtt* m = mqtt(); + if (!m) { + return nullptr; + } + + const HADevice* d = m->getDevice(); + if (d && d->isSharedAvailabilityEnabled()) { + return d->getPayloadNotAvailable(); + } + + return nullptr; +} + +void HABaseDeviceType::configureAvailabilityEntries(HASerializer* serializer) const +{ + if (!serializer || !mqtt()) { + return; + } + + const HADevice* device = mqtt()->getDevice(); + if (!device) { + return; + } + + const bool isSharedAvailability = device->isSharedAvailabilityEnabled(); + const bool availabilityActive = isAvailabilityConfigured(); + + if (!isSharedAvailability && !availabilityActive) { + return; + } + + const char* pa = effectivePayloadAvailable(); + if (nonEmptyString(pa)) { + serializer->set( + AHATOFSTR(HAPayloadAvailableProperty), + pa, + HASerializer::ConstCharPropertyValue + ); + } + + const char* pn = effectivePayloadNotAvailable(); + if (nonEmptyString(pn)) { + serializer->set( + AHATOFSTR(HAPayloadNotAvailableProperty), + pn, + HASerializer::ConstCharPropertyValue + ); + } + + if (nonEmptyString(_availabilityMode)) { + serializer->set( + AHATOFSTR(HAAvailabilityModeProperty), + _availabilityMode, + HASerializer::ConstCharPropertyValue + ); + } + + const bool useList = + !isSharedAvailability + && availabilityActive + && _availabilityList.count() > 0; + + if (useList) { + auto* entry = serializer->addEntry(); + entry->type = HASerializer::AvailabilityArrayEntryType; + entry->subtype = 0; + entry->property = AHATOFSTR(HAAvailabilityListProperty); + entry->value = const_cast(&_availabilityList); + return; + } + + auto* e = serializer->addEntry(); + e->type = HASerializer::TopicEntryType; + e->subtype = 0; + e->property = AHATOFSTR(HAAvailabilityTopic); + e->value = isSharedAvailability + ? const_cast(device->getAvailabilityTopic()) + : nullptr; } \ No newline at end of file diff --git a/src/device-types/HABaseDeviceType.h b/src/device-types/HABaseDeviceType.h index c934624..e039dac 100644 --- a/src/device-types/HABaseDeviceType.h +++ b/src/device-types/HABaseDeviceType.h @@ -3,6 +3,8 @@ #include #include "../ArduinoHADefines.h" +#include "../utils/HAAvailabilityConfig.h" +#include "../utils/HANumeric.h" class HAMqtt; class HASerializer; @@ -127,6 +129,36 @@ public: inline const char* getEntityCategory() const { return _entityCategory; } + void setEnabledByDefault(bool enabled); + void clearEnabledByDefault(); + + void setEntityPicture(const char* url); + void clearEntityPicture(); + + void setQos(uint8_t qos); + void clearQos(); + + void setEncoding(const char* encoding); + void clearEncoding(); + + void setPayloadAvailable(const char* payload); + void setPayloadNotAvailable(const char* payload); + + void setAvailabilityMode(const char* mode); + + /** + * Adds a full-topic availability entry for discovery (`avty` list) and publishing. + * Only used when this entity does not use shared device availability. + */ + bool addAvailabilityEntry( + const char* topic, + const char* valueTemplate = nullptr, + const char* payloadAvailable = nullptr, + const char* payloadNotAvailable = nullptr + ); + + void clearAvailabilityEntries(); + /** * Sets availability of the device type. * Setting the initial availability enables availability reporting for this device type. @@ -186,6 +218,19 @@ protected: */ virtual void buildSerializer() { }; + /** + * Writes shared MQTT discovery fields (enabled_by_default, entity_picture, qos, encoding). + */ + void applyCommonEntityProperties( + HASerializer* serializer, + bool includeEncoding = true + ) const; + + /** + * Populates availability-related discovery entries. Called from HASerializer::WithAvailability. + */ + void configureAvailabilityEntries(HASerializer* serializer) const; + /** * This method is called each time the MQTT connection is acquired. * Each device type should publish its configuration and availability. @@ -266,6 +311,11 @@ protected: bool isProgmemData = false ); + /** + * Publishes a RAM payload on an already fully-qualified MQTT topic. + */ + bool publishAbsolute(const char* fullTopic, const char* payload, bool retained = false); + /** * Adds the preferred entity ID property to the serializer. * `default_entity_id` takes precedence and the legacy `object_id` is only @@ -310,6 +360,17 @@ protected: /// HASerializer that belongs to this device type. It can be nullptr. HASerializer* _serializer; + bool _hasEnabledByDefault; + bool _enabledByDefault; + const char* _entityPicture; + bool _hasQos; + HANumeric _qosNumeric; + const char* _encoding; + const char* _payloadAvailable; + const char* _payloadNotAvailable; + const char* _availabilityMode; + HAAvailabilityConfig _availabilityList; + private: enum Availability { AvailabilityDefault = 0, @@ -319,7 +380,12 @@ private: /// The current availability of this device type. AvailabilityDefault means that the initial availability was never set. Availability _availability; + + const char* effectivePayloadAvailable() const; + const char* effectivePayloadNotAvailable() const; + friend class HAMqtt; + friend class HASerializer; }; #endif diff --git a/src/device-types/HABinarySensor.cpp b/src/device-types/HABinarySensor.cpp index f72759d..130e740 100644 --- a/src/device-types/HABinarySensor.cpp +++ b/src/device-types/HABinarySensor.cpp @@ -2,12 +2,18 @@ #ifndef EX_ARDUINOHA_BINARY_SENSOR #include "../HAMqtt.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" HABinarySensor::HABinarySensor(const char* uniqueId) : HABaseDeviceType(AHATOFSTR(HAComponentBinarySensor), uniqueId), _class(nullptr), _icon(nullptr), + _payloadOn(nullptr), + _payloadOff(nullptr), + _forceUpdate(false), + _offDelay(), + _valueTemplate(nullptr), _currentState(false) { @@ -19,12 +25,9 @@ bool HABinarySensor::setState(const bool state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HABinarySensor::setExpireAfter(uint16_t expireAfter) @@ -36,20 +39,68 @@ void HABinarySensor::setExpireAfter(uint16_t expireAfter) } } +void HABinarySensor::setOffDelay(uint16_t seconds) +{ + if (seconds > 0) { + _offDelay.setBaseValue(seconds); + } else { + _offDelay.reset(); + } +} + +void HABinarySensor::clearOffDelay() +{ + _offDelay.reset(); +} + +void HABinarySensor::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + void HABinarySensor::buildSerializer() { if (_serializer || !uniqueId()) { return; } - _serializer = new HASerializer(this, 10); // 10 - max properties nb + _serializer = new HASerializer(this, 20); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _class); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadOn)) { + _serializer->set(AHATOFSTR(HAPayloadOnProperty), _payloadOn); + } + + if (nonEmptyString(_payloadOff)) { + _serializer->set(AHATOFSTR(HAPayloadOffProperty), _payloadOff); + } + + if (_forceUpdate) { + _serializer->set( + AHATOFSTR(HAForceUpdateProperty), + &_forceUpdate, + HASerializer::BoolPropertyType + ); + } + + if (_offDelay.isSet()) { + _serializer->set( + AHATOFSTR(HAOffDelayProperty), + &_offDelay, + HASerializer::NumberPropertyType + ); + } + + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + if (_expireAfter.isSet()) { _serializer->set( AHATOFSTR(HAExpireAfterProperty), @@ -69,7 +120,7 @@ HASerializer* HABinarySensor::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 10); + HASerializer* serializer = new HASerializer(this, 20); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentBinarySensor), @@ -78,10 +129,39 @@ HASerializer* HABinarySensor::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _class); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadOn)) { + serializer->set(AHATOFSTR(HAPayloadOnProperty), _payloadOn); + } + + if (nonEmptyString(_payloadOff)) { + serializer->set(AHATOFSTR(HAPayloadOffProperty), _payloadOff); + } + + if (_forceUpdate) { + serializer->set( + AHATOFSTR(HAForceUpdateProperty), + &_forceUpdate, + HASerializer::BoolPropertyType + ); + } + + if (_offDelay.isSet()) { + serializer->set( + AHATOFSTR(HAOffDelayProperty), + &_offDelay, + HASerializer::NumberPropertyType + ); + } + + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + if (_expireAfter.isSet()) { serializer->set( AHATOFSTR(HAExpireAfterProperty), @@ -110,9 +190,33 @@ void HABinarySensor::onMqttConnected() bool HABinarySensor::publishState(const bool state) { + if (state) { + if (nonEmptyString(_payloadOn)) { + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + _payloadOn, + true + ); + } + + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + AHATOFSTR(HAStateOn), + true + ); + } + + if (nonEmptyString(_payloadOff)) { + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + _payloadOff, + true + ); + } + return publishOnDataTopic( AHATOFSTR(HAStateTopic), - AHATOFSTR(state ? HAStateOn : HAStateOff), + AHATOFSTR(HAStateOff), true ); } diff --git a/src/device-types/HABinarySensor.h b/src/device-types/HABinarySensor.h index 170c5da..caa386a 100644 --- a/src/device-types/HABinarySensor.h +++ b/src/device-types/HABinarySensor.h @@ -72,6 +72,20 @@ public: inline void setIcon(const char* icon) { _icon = icon; } + inline void setPayloadOn(const char* payload) + { _payloadOn = payload; } + + inline void setPayloadOff(const char* payload) + { _payloadOff = payload; } + + inline void setForceUpdate(bool forceUpdate) + { _forceUpdate = forceUpdate; } + + void setOffDelay(uint16_t seconds); + void clearOffDelay(); + + void setValueTemplate(const char* valueTemplate); + protected: virtual void buildSerializer() override; virtual HASerializer* buildDeviceDiscoverySerializer() override; @@ -97,6 +111,12 @@ private: /// It defines the number of seconds after the sensor’s state expires, if it’s not updated. By default the sensors state never expires. HANumeric _expireAfter; + const char* _payloadOn; + const char* _payloadOff; + bool _forceUpdate; + HANumeric _offDelay; + const char* _valueTemplate; + /// Current state of the sensor. By default it's false. bool _currentState; diff --git a/src/device-types/HAButton.cpp b/src/device-types/HAButton.cpp index d9d16a8..5115152 100644 --- a/src/device-types/HAButton.cpp +++ b/src/device-types/HAButton.cpp @@ -2,32 +2,55 @@ #ifndef EX_ARDUINOHA_BUTTON #include "../HAMqtt.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" +#include HAButton::HAButton(const char* uniqueId) : HABaseDeviceType(AHATOFSTR(HAComponentButton), uniqueId), _class(nullptr), _icon(nullptr), _retain(false), + _payloadPress(nullptr), + _commandTemplate(nullptr), _commandCallback(nullptr) { } +void HAButton::setPayloadPress(const char* payload) +{ + _payloadPress = payload; +} + +void HAButton::setCommandTemplate(const char* commandTemplate) +{ + _commandTemplate = commandTemplate; +} + void HAButton::buildSerializer() { if (_serializer || !uniqueId()) { return; } - _serializer = new HASerializer(this, 10); // 10 - max properties nb + _serializer = new HASerializer(this, 18); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _class); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadPress)) { + _serializer->set(AHATOFSTR(HAPayloadPressProperty), _payloadPress); + } + + if (nonEmptyString(_commandTemplate)) { + _serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + // optional property if (_retain) { _serializer->set( @@ -48,7 +71,7 @@ HASerializer* HAButton::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 10); + HASerializer* serializer = new HASerializer(this, 18); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentButton), @@ -57,10 +80,19 @@ HASerializer* HAButton::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _class); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadPress)) { + serializer->set(AHATOFSTR(HAPayloadPressProperty), _payloadPress); + } + + if (nonEmptyString(_commandTemplate)) { + serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_retain) { serializer->set( AHATOFSTR(HARetainProperty), @@ -108,6 +140,16 @@ void HAButton::onMqttMessage( uniqueId(), AHATOFSTR(HACommandTopic) )) { + if ( + nonEmptyString(_payloadPress) + && ( + length != strlen(_payloadPress) + || memcmp(payload, _payloadPress, length) != 0 + ) + ) { + return; + } + if (_commandCallback) { _commandCallback(this); } diff --git a/src/device-types/HAButton.h b/src/device-types/HAButton.h index c4d094e..a96437d 100644 --- a/src/device-types/HAButton.h +++ b/src/device-types/HAButton.h @@ -54,6 +54,9 @@ public: inline void setRetain(const bool retain) { _retain = retain; } + void setPayloadPress(const char* payload); + void setCommandTemplate(const char* commandTemplate); + /** * Registers callback that will be called each time the press command from HA is received. * Please note that it's not possible to register multiple callbacks for the same button. @@ -104,6 +107,9 @@ private: /// The retain flag for the HA commands. bool _retain; + const char* _payloadPress; + const char* _commandTemplate; + /// The command callback that will be called once clicking the button in HA panel. HABUTTON_CALLBACK(_commandCallback); diff --git a/src/device-types/HACamera.cpp b/src/device-types/HACamera.cpp index 78bf697..5883849 100644 --- a/src/device-types/HACamera.cpp +++ b/src/device-types/HACamera.cpp @@ -27,10 +27,11 @@ void HACamera::buildSerializer() return; } - _serializer = new HASerializer(this, 9); // 9 - max properties nb + _serializer = new HASerializer(this, 18); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer, false); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); _serializer->set( @@ -49,7 +50,7 @@ HASerializer* HACamera::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 9); + HASerializer* serializer = new HASerializer(this, 18); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentCamera), @@ -58,6 +59,7 @@ HASerializer* HACamera::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer, false); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); serializer->set( diff --git a/src/device-types/HACover.cpp b/src/device-types/HACover.cpp index 26112a4..66ecd03 100644 --- a/src/device-types/HACover.cpp +++ b/src/device-types/HACover.cpp @@ -29,12 +29,13 @@ bool HACover::setState(const CoverState state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; + if (state == StateUnknown) { + return false; } - return false; + const bool published = publishState(state); + _currentState = state; + return published; } bool HACover::setPosition(const int16_t position, const bool force) @@ -43,12 +44,13 @@ bool HACover::setPosition(const int16_t position, const bool force) return true; } - if (publishPosition(position)) { - _currentPosition = position; - return true; + if (position == DefaultPosition || !(_features & PositionFeature)) { + return false; } - return false; + const bool published = publishPosition(position); + _currentPosition = position; + return published; } void HACover::buildSerializer() @@ -57,10 +59,11 @@ void HACover::buildSerializer() return; } - _serializer = new HASerializer(this, 13); // 13 - max properties nb + _serializer = new HASerializer(this, 22); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _class); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -97,7 +100,7 @@ HASerializer* HACover::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 13); + HASerializer* serializer = new HASerializer(this, 22); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentCover), @@ -106,6 +109,7 @@ HASerializer* HACover::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _class); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HADeviceTracker.cpp b/src/device-types/HADeviceTracker.cpp index ae8e26d..5099093 100644 --- a/src/device-types/HADeviceTracker.cpp +++ b/src/device-types/HADeviceTracker.cpp @@ -19,12 +19,13 @@ bool HADeviceTracker::setState(const TrackerState state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; + if (state == StateUnknown) { + return false; } - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HADeviceTracker::buildSerializer() @@ -33,10 +34,11 @@ void HADeviceTracker::buildSerializer() return; } - _serializer = new HASerializer(this, 9); // 9 - max properties nb + _serializer = new HASerializer(this, 18); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); _serializer->set( @@ -55,7 +57,7 @@ HASerializer* HADeviceTracker::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 9); + HASerializer* serializer = new HASerializer(this, 18); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentDeviceTracker), @@ -64,6 +66,7 @@ HASerializer* HADeviceTracker::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); serializer->set( diff --git a/src/device-types/HAFan.cpp b/src/device-types/HAFan.cpp index 8772bd2..150fc3e 100644 --- a/src/device-types/HAFan.cpp +++ b/src/device-types/HAFan.cpp @@ -31,12 +31,9 @@ bool HAFan::setState(const bool state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } bool HAFan::setSpeed(const uint16_t speed, const bool force) @@ -45,12 +42,13 @@ bool HAFan::setSpeed(const uint16_t speed, const bool force) return true; } - if (publishSpeed(speed)) { - _currentSpeed = speed; - return true; + if (!(_features & SpeedsFeature)) { + return false; } - return false; + const bool published = publishSpeed(speed); + _currentSpeed = speed; + return published; } void HAFan::buildSerializer() @@ -59,10 +57,11 @@ void HAFan::buildSerializer() return; } - _serializer = new HASerializer(this, 15); // 15 - max properties nb + _serializer = new HASerializer(this, 24); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -115,7 +114,7 @@ HASerializer* HAFan::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 15); + HASerializer* serializer = new HASerializer(this, 24); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentFan), @@ -124,6 +123,7 @@ HASerializer* HAFan::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HAHVAC.cpp b/src/device-types/HAHVAC.cpp index 450c80f..92d46cb 100644 --- a/src/device-types/HAHVAC.cpp +++ b/src/device-types/HAHVAC.cpp @@ -89,12 +89,9 @@ bool HAHVAC::setCurrentTemperature(const HANumeric& temperature, const bool forc return true; } - if (publishCurrentTemperature(temperature)) { - _currentTemperature = temperature; - return true; - } - - return false; + const bool published = publishCurrentTemperature(temperature); + _currentTemperature = temperature; + return published; } bool HAHVAC::setAction(const Action action, const bool force) @@ -103,12 +100,9 @@ bool HAHVAC::setAction(const Action action, const bool force) return true; } - if (publishAction(action)) { - _action = action; - return true; - } - - return false; + const bool published = publishAction(action); + _action = action; + return published; } bool HAHVAC::setAuxState(const bool state, const bool force) @@ -117,12 +111,9 @@ bool HAHVAC::setAuxState(const bool state, const bool force) return true; } - if (publishAuxState(state)) { - _auxState = state; - return true; - } - - return false; + const bool published = publishAuxState(state); + _auxState = state; + return published; } bool HAHVAC::setFanMode(const FanMode mode, const bool force) @@ -131,12 +122,9 @@ bool HAHVAC::setFanMode(const FanMode mode, const bool force) return true; } - if (publishFanMode(mode)) { - _fanMode = mode; - return true; - } - - return false; + const bool published = publishFanMode(mode); + _fanMode = mode; + return published; } bool HAHVAC::setSwingMode(const SwingMode mode, const bool force) @@ -145,12 +133,9 @@ bool HAHVAC::setSwingMode(const SwingMode mode, const bool force) return true; } - if (publishSwingMode(mode)) { - _swingMode = mode; - return true; - } - - return false; + const bool published = publishSwingMode(mode); + _swingMode = mode; + return published; } bool HAHVAC::setMode(const Mode mode, const bool force) @@ -159,12 +144,9 @@ bool HAHVAC::setMode(const Mode mode, const bool force) return true; } - if (publishMode(mode)) { - _mode = mode; - return true; - } - - return false; + const bool published = publishMode(mode); + _mode = mode; + return published; } bool HAHVAC::setTargetTemperature(const HANumeric& temperature, const bool force) @@ -177,12 +159,9 @@ bool HAHVAC::setTargetTemperature(const HANumeric& temperature, const bool force return true; } - if (publishTargetTemperature(temperature)) { - _targetTemperature = temperature; - return true; - } - - return false; + const bool published = publishTargetTemperature(temperature); + _targetTemperature = temperature; + return published; } void HAHVAC::buildSerializer() @@ -191,10 +170,11 @@ void HAHVAC::buildSerializer() return; } - _serializer = new HASerializer(this, 29); // 29 - max properties nb + _serializer = new HASerializer(this, 40); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -369,7 +349,7 @@ HASerializer* HAHVAC::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 29); + HASerializer* serializer = new HASerializer(this, 40); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentClimate), @@ -378,6 +358,7 @@ HASerializer* HAHVAC::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HALight.cpp b/src/device-types/HALight.cpp index 7de92a3..d441c8d 100644 --- a/src/device-types/HALight.cpp +++ b/src/device-types/HALight.cpp @@ -78,12 +78,9 @@ bool HALight::setState(const bool state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } bool HALight::setBrightness(const uint8_t brightness, const bool force) @@ -92,12 +89,13 @@ bool HALight::setBrightness(const uint8_t brightness, const bool force) return true; } - if (publishBrightness(brightness)) { - _currentBrightness = brightness; - return true; + if (!(_features & BrightnessFeature)) { + return false; } - return false; + const bool published = publishBrightness(brightness); + _currentBrightness = brightness; + return published; } bool HALight::setColorTemperature(const uint16_t temperature, const bool force) @@ -106,12 +104,13 @@ bool HALight::setColorTemperature(const uint16_t temperature, const bool force) return true; } - if (publishColorTemperature(temperature)) { - _currentColorTemperature = temperature; - return true; + if (!(_features & ColorTemperatureFeature)) { + return false; } - return false; + const bool published = publishColorTemperature(temperature); + _currentColorTemperature = temperature; + return published; } bool HALight::setRGBColor(const RGBColor& color, const bool force) @@ -120,12 +119,13 @@ bool HALight::setRGBColor(const RGBColor& color, const bool force) return true; } - if (publishRGBColor(color)) { - _currentRGBColor = color; - return true; + if (!(_features & RGBFeature) || !color.isSet) { + return false; } - return false; + const bool published = publishRGBColor(color); + _currentRGBColor = color; + return published; } void HALight::buildSerializer() @@ -134,10 +134,11 @@ void HALight::buildSerializer() return; } - _serializer = new HASerializer(this, 20); // 20 - max properties nb + _serializer = new HASerializer(this, 30); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -208,7 +209,7 @@ HASerializer* HALight::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 20); + HASerializer* serializer = new HASerializer(this, 30); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentLight), @@ -217,6 +218,7 @@ HASerializer* HALight::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HALock.cpp b/src/device-types/HALock.cpp index 25f92f1..c7295fa 100644 --- a/src/device-types/HALock.cpp +++ b/src/device-types/HALock.cpp @@ -24,12 +24,13 @@ bool HALock::setState(const LockState state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; + if (state == StateUnknown) { + return false; } - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HALock::buildSerializer() @@ -38,10 +39,11 @@ void HALock::buildSerializer() return; } - _serializer = new HASerializer(this, 11); // 11 - max properties nb + _serializer = new HASerializer(this, 20); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -73,7 +75,7 @@ HASerializer* HALock::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 11); + HASerializer* serializer = new HASerializer(this, 20); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentLock), @@ -82,6 +84,7 @@ HASerializer* HALock::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HANumber.cpp b/src/device-types/HANumber.cpp index 55ed506..8a9c73e 100644 --- a/src/device-types/HANumber.cpp +++ b/src/device-types/HANumber.cpp @@ -2,7 +2,9 @@ #ifndef EX_ARDUINOHA_NUMBER #include "../HAMqtt.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" +#include HANumber::HANumber(const char* uniqueId, const NumberPrecision precision) : HABaseDeviceType(AHATOFSTR(HAComponentNumber), uniqueId), @@ -17,23 +19,38 @@ HANumber::HANumber(const char* uniqueId, const NumberPrecision precision) : _maxValue(), _step(), _currentState(), - _commandCallback(nullptr) + _commandCallback(nullptr), + _commandTemplate(nullptr), + _valueTemplate(nullptr), + _payloadReset(nullptr) { } +void HANumber::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + +void HANumber::setCommandTemplate(const char* commandTemplate) +{ + _commandTemplate = commandTemplate; +} + +void HANumber::setPayloadReset(const char* payloadReset) +{ + _payloadReset = payloadReset; +} + bool HANumber::setState(const HANumeric& state, const bool force) { if (!force && state == _currentState) { return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HANumber::updateMinMaxStep(const float min, const float max, const float step) @@ -50,10 +67,11 @@ void HANumber::buildSerializer() return; } - _serializer = new HASerializer(this, 17); // 17 - max properties nb + _serializer = new HASerializer(this, 24); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _class); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -63,11 +81,27 @@ void HANumber::buildSerializer() getModeProperty(), HASerializer::ProgmemPropertyValue ); - _serializer->set( - AHATOFSTR(HACommandTemplateProperty), - getCommandTemplate(), - HASerializer::ProgmemPropertyValue - ); + + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_payloadReset)) { + _serializer->set(AHATOFSTR(HAPayloadResetProperty), _payloadReset); + } + + if (nonEmptyString(_commandTemplate)) { + _serializer->set( + AHATOFSTR(HACommandTemplateProperty), + _commandTemplate + ); + } else if (getBuiltInCommandTemplate()) { + _serializer->set( + AHATOFSTR(HACommandTemplateProperty), + getBuiltInCommandTemplate(), + HASerializer::ProgmemPropertyValue + ); + } if (_minValue.isSet()) { _serializer->set( @@ -121,7 +155,7 @@ HASerializer* HANumber::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 17); + HASerializer* serializer = new HASerializer(this, 24); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentNumber), @@ -130,6 +164,7 @@ HASerializer* HANumber::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _class); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -139,11 +174,27 @@ HASerializer* HANumber::buildDeviceDiscoverySerializer() getModeProperty(), HASerializer::ProgmemPropertyValue ); - serializer->set( - AHATOFSTR(HACommandTemplateProperty), - getCommandTemplate(), - HASerializer::ProgmemPropertyValue - ); + + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_payloadReset)) { + serializer->set(AHATOFSTR(HAPayloadResetProperty), _payloadReset); + } + + if (nonEmptyString(_commandTemplate)) { + serializer->set( + AHATOFSTR(HACommandTemplateProperty), + _commandTemplate + ); + } else if (getBuiltInCommandTemplate()) { + serializer->set( + AHATOFSTR(HACommandTemplateProperty), + getBuiltInCommandTemplate(), + HASerializer::ProgmemPropertyValue + ); + } if (_minValue.isSet()) { serializer->set( @@ -263,6 +314,23 @@ void HANumber::handleCommand(const uint8_t* cmd, const uint16_t length) return; } + if ( + _payloadReset + && _payloadReset[0] != '\0' + && length == strlen(_payloadReset) + && memcmp(cmd, _payloadReset, length) == 0 + ) { + if (_commandCallback) { + _commandCallback(HANumeric(), this); + } +#if defined(ARDUINOHA_ENABLE_STDFUNCTION) + if (_commandStdCallback) { + _commandStdCallback(HANumeric(), this); + } +#endif + return; + } + if (memcmp_P(cmd, HAStateNone, length) == 0) { if (_commandCallback) { _commandCallback(HANumeric(), this); @@ -302,7 +370,7 @@ const __FlashStringHelper* HANumber::getModeProperty() const } } -const __FlashStringHelper* HANumber::getCommandTemplate() +const __FlashStringHelper* HANumber::getBuiltInCommandTemplate() const { switch (_precision) { case PrecisionP1: diff --git a/src/device-types/HANumber.h b/src/device-types/HANumber.h index 9e4ff0d..08591c4 100644 --- a/src/device-types/HANumber.h +++ b/src/device-types/HANumber.h @@ -190,6 +190,10 @@ public: */ void updateMinMaxStep(const float min, const float max, const float step); + void setValueTemplate(const char* valueTemplate); + void setCommandTemplate(const char* commandTemplate); + void setPayloadReset(const char* payloadReset); + /** * Registers callback that will be called each time the number is changed in the HA panel. * Please note that it's not possible to register multiple callbacks for the same number. @@ -254,9 +258,9 @@ private: const __FlashStringHelper* getModeProperty() const; /** - * Returns progmem string representing value template for the command. + * Built-in command template used for float precision (when no custom template is set). */ - const __FlashStringHelper* getCommandTemplate(); + const __FlashStringHelper* getBuiltInCommandTemplate() const; /// The precision of the number. By default it's `HANumber::PrecisionP0`. const NumberPrecision _precision; @@ -294,6 +298,10 @@ private: /// The callback that will be called when the command is received from the HA. HANUMBER_CALLBACK(_commandCallback); + const char* _commandTemplate; + const char* _valueTemplate; + const char* _payloadReset; + #if defined(ARDUINOHA_ENABLE_STDFUNCTION) /// The std::function callback that will be called when the command is received from the HA. std::function _commandStdCallback; diff --git a/src/device-types/HAScene.cpp b/src/device-types/HAScene.cpp index 0853a6b..6c4d9d9 100644 --- a/src/device-types/HAScene.cpp +++ b/src/device-types/HAScene.cpp @@ -22,10 +22,11 @@ void HAScene::buildSerializer() return; } - _serializer = new HASerializer(this, 9); // 9 - max properties nb + _serializer = new HASerializer(this, 18); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -55,7 +56,7 @@ HASerializer* HAScene::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 9); + HASerializer* serializer = new HASerializer(this, 18); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentScene), @@ -64,6 +65,7 @@ HASerializer* HAScene::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); diff --git a/src/device-types/HASelect.cpp b/src/device-types/HASelect.cpp index ab4feff..9d643f0 100644 --- a/src/device-types/HASelect.cpp +++ b/src/device-types/HASelect.cpp @@ -2,6 +2,8 @@ #ifndef EX_ARDUINOHA_SELECT #include "../HAMqtt.h" +#include "../utils/HAUtils.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" HASelect::HASelect(const char* uniqueId) : @@ -11,6 +13,8 @@ HASelect::HASelect(const char* uniqueId) : _icon(nullptr), _retain(false), _optimistic(false), + _valueTemplate(nullptr), + _commandTemplate(nullptr), _commandCallback(nullptr) #if defined(ARDUINOHA_ENABLE_STDFUNCTION) , _commandStdCallback() @@ -35,13 +39,23 @@ HASelect::~HASelect() } } +void HASelect::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + +void HASelect::setCommandTemplate(const char* commandTemplate) +{ + _commandTemplate = commandTemplate; +} + void HASelect::setOptions(const char* options) { if (!options || _options) { // options can be set only once return; } - const uint16_t optionsNb = countOptionsInString(options); + const uint16_t optionsNb = HAUtils::countSemicolonSeparatedOptions(options); if (optionsNb == 0) { return; } @@ -83,12 +97,13 @@ bool HASelect::setState(const int8_t state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; + if (!_options || state >= _options->getItemsNb()) { + return false; } - return false; + const bool published = publishState(state); + _currentState = state; + return published; } const char* HASelect::getCurrentOption() const @@ -102,10 +117,11 @@ void HASelect::buildSerializer() return; } - _serializer = new HASerializer(this, 12); // 12 - max properties nb + _serializer = new HASerializer(this, 20); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); _serializer->set( @@ -114,6 +130,14 @@ void HASelect::buildSerializer() HASerializer::ArrayPropertyType ); + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + _serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_retain) { _serializer->set( AHATOFSTR(HARetainProperty), @@ -142,7 +166,7 @@ HASerializer* HASelect::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 12); + HASerializer* serializer = new HASerializer(this, 20); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentSelect), @@ -151,6 +175,7 @@ HASerializer* HASelect::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); serializer->set( @@ -159,6 +184,14 @@ HASerializer* HASelect::buildDeviceDiscoverySerializer() HASerializer::ArrayPropertyType ); + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_retain) { serializer->set( AHATOFSTR(HARetainProperty), @@ -256,25 +289,7 @@ bool HASelect::publishState(const int8_t state) uint8_t HASelect::countOptionsInString(const char* options) const { - // the given string is treated as a single option if there are no semicolons - uint8_t optionsNb = 1; - const uint16_t optionsLen = strlen(options); - - if (optionsLen == 0) { - return 0; - } - - for (uint16_t i = 0; i < optionsLen; i++) { - if (options[i] == ';') { - if (optionsNb == 255) { - break; - } - - optionsNb++; - } - } - - return optionsNb; + return HAUtils::countSemicolonSeparatedOptions(options); } #endif diff --git a/src/device-types/HASelect.h b/src/device-types/HASelect.h index 858b227..7c47e80 100644 --- a/src/device-types/HASelect.h +++ b/src/device-types/HASelect.h @@ -105,6 +105,9 @@ public: inline void setOptimistic(const bool optimistic) { _optimistic = optimistic; } + void setValueTemplate(const char* valueTemplate); + void setCommandTemplate(const char* commandTemplate); + /** * Registers callback that will be called each time the option is changed from the HA panel. * Please note that it's not possible to register multiple callbacks for the same select. @@ -180,6 +183,9 @@ private: /// The optimistic mode of the select (`true` - enabled, `false` - disabled). bool _optimistic; + const char* _valueTemplate; + const char* _commandTemplate; + /// The command callback that will be called when option is changed via the HA panel. HASELECT_CALLBACK(_commandCallback); diff --git a/src/device-types/HASensor.cpp b/src/device-types/HASensor.cpp index 1df8429..5cfc724 100644 --- a/src/device-types/HASensor.cpp +++ b/src/device-types/HASensor.cpp @@ -2,7 +2,11 @@ #ifndef EX_ARDUINOHA_SENSOR #include "../HAMqtt.h" +#include "../utils/HAUtils.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" +#include "../utils/HASerializerArray.h" +#include HASensor::HASensor(const char* uniqueId, const uint16_t features) : HABaseDeviceType(AHATOFSTR(HAComponentSensor), uniqueId), @@ -12,11 +16,99 @@ HASensor::HASensor(const char* uniqueId, const uint16_t features) : _forceUpdate(false), _icon(nullptr), _unitOfMeasurement(nullptr), - _expireAfter() + _expireAfter(), + _options(nullptr), + _suggestedDisplayPrecision(), + _valueTemplate(nullptr), + _jsonAttributesTemplate(nullptr), + _lastResetValueTemplate(nullptr) { } +HASensor::~HASensor() +{ + if (_options) { + const uint8_t optionsNb = _options->getItemsNb(); + const HASerializerArray::ItemType* options = _options->getItems(); + + if (optionsNb > 1) { + for (uint8_t i = 0; i < optionsNb; i++) { + delete options[i]; + } + } + + delete _options; + } +} + +void HASensor::setSuggestedDisplayPrecision(uint8_t precision) +{ + _suggestedDisplayPrecision = HANumeric(static_cast(precision), 0); +} + +void HASensor::clearSuggestedDisplayPrecision() +{ + _suggestedDisplayPrecision.reset(); +} + +void HASensor::setOptions(const char* options) +{ + if (!options || _options) { + return; + } + + const uint16_t optionsNb = HAUtils::countSemicolonSeparatedOptions(options); + if (optionsNb == 0) { + return; + } + + const uint16_t optionsLen = strlen(options) + 1; + _options = new HASerializerArray(optionsNb, false); + + if (optionsNb == 1) { + _options->add(options); + return; + } + + uint16_t optionLen = 0; + for (uint16_t i = 0; i < optionsLen; i++) { + if (options[i] == ';' || options[i] == 0) { + if (optionLen == 0) { + break; + } + + char* option = new char[optionLen + 1]; + option[optionLen] = 0; + memcpy(option, &options[i - optionLen], optionLen); + + if (!_options->add(option)) { + delete[] option; + break; + } + optionLen = 0; + continue; + } + + optionLen++; + } +} + +void HASensor::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + +void HASensor::setJsonAttributesTemplate(const char* jsonAttributesTemplate) +{ + _jsonAttributesTemplate = jsonAttributesTemplate; +} + +void HASensor::setLastResetValueTemplate(const char* lastResetValueTemplate) +{ + _lastResetValueTemplate = lastResetValueTemplate; +} + bool HASensor::setValue(const char* value) { if (!value) { @@ -46,16 +138,44 @@ void HASensor::buildSerializer() return; } - _serializer = new HASerializer(this, 14); // 14 - max properties nb + _serializer = new HASerializer(this, 24); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _deviceClass); _serializer->set(AHATOFSTR(HAStateClassProperty), _stateClass); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); _serializer->set(AHATOFSTR(HAUnitOfMeasurementProperty), nonEmptyString(_unitOfMeasurement)); + if (_suggestedDisplayPrecision.isSet()) { + _serializer->set( + AHATOFSTR(HASuggestedDisplayPrecisionProperty), + &_suggestedDisplayPrecision, + HASerializer::NumberPropertyType + ); + } + + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_lastResetValueTemplate)) { + _serializer->set( + AHATOFSTR(HALastResetValueTemplateProperty), + _lastResetValueTemplate + ); + } + + if (_options) { + _serializer->set( + AHATOFSTR(HASensorOptionsProperty), + _options, + HASerializer::ArrayPropertyType + ); + } + if (_forceUpdate) { _serializer->set( AHATOFSTR(HAForceUpdateProperty), @@ -74,6 +194,12 @@ void HASensor::buildSerializer() if (_features & JsonAttributesFeature) { _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + if (nonEmptyString(_jsonAttributesTemplate)) { + _serializer->set( + AHATOFSTR(HAJsonAttributesTemplateProperty), + _jsonAttributesTemplate + ); + } } _serializer->set(HASerializer::WithDevice); @@ -87,7 +213,7 @@ HASerializer* HASensor::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 14); + HASerializer* serializer = new HASerializer(this, 24); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentSensor), @@ -96,12 +222,40 @@ HASerializer* HASensor::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _deviceClass); serializer->set(AHATOFSTR(HAStateClassProperty), _stateClass); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); serializer->set(AHATOFSTR(HAUnitOfMeasurementProperty), nonEmptyString(_unitOfMeasurement)); + if (_suggestedDisplayPrecision.isSet()) { + serializer->set( + AHATOFSTR(HASuggestedDisplayPrecisionProperty), + &_suggestedDisplayPrecision, + HASerializer::NumberPropertyType + ); + } + + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_lastResetValueTemplate)) { + serializer->set( + AHATOFSTR(HALastResetValueTemplateProperty), + _lastResetValueTemplate + ); + } + + if (_options) { + serializer->set( + AHATOFSTR(HASensorOptionsProperty), + _options, + HASerializer::ArrayPropertyType + ); + } + if (_forceUpdate) { serializer->set( AHATOFSTR(HAForceUpdateProperty), @@ -120,6 +274,12 @@ HASerializer* HASensor::buildDeviceDiscoverySerializer() if (_features & JsonAttributesFeature) { serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + if (nonEmptyString(_jsonAttributesTemplate)) { + serializer->set( + AHATOFSTR(HAJsonAttributesTemplateProperty), + _jsonAttributesTemplate + ); + } } serializer->set(HASerializer::WithAvailability); diff --git a/src/device-types/HASensor.h b/src/device-types/HASensor.h index 559d796..2fb19ed 100644 --- a/src/device-types/HASensor.h +++ b/src/device-types/HASensor.h @@ -4,6 +4,8 @@ #include "HABaseDeviceType.h" #include "../utils/HANumeric.h" +class HASerializerArray; + #ifndef EX_ARDUINOHA_SENSOR /** @@ -29,6 +31,8 @@ public: */ HASensor(const char* uniqueId, const uint16_t features = DefaultFeatures); + virtual ~HASensor(); + /** * Publishes the MQTT message with the given value. * Unlike the other device types, the HASensor doesn't store the previous value that was set. @@ -99,6 +103,18 @@ public: inline void setUnitOfMeasurement(const char* unitOfMeasurement) { _unitOfMeasurement = unitOfMeasurement; } + void setSuggestedDisplayPrecision(uint8_t precision); + void clearSuggestedDisplayPrecision(); + + /** + * Semicolon-separated options for device_class `enum`. Set only once. + */ + void setOptions(const char* options); + + void setValueTemplate(const char* valueTemplate); + void setJsonAttributesTemplate(const char* jsonAttributesTemplate); + void setLastResetValueTemplate(const char* lastResetValueTemplate); + protected: virtual void buildSerializer() override final; virtual HASerializer* buildDeviceDiscoverySerializer() override; @@ -127,6 +143,12 @@ private: /// It defines the number of seconds after the sensor’s state expires, if it’s not updated. By default the sensors state never expires. HANumeric _expireAfter; + + HASerializerArray* _options; + HANumeric _suggestedDisplayPrecision; + const char* _valueTemplate; + const char* _jsonAttributesTemplate; + const char* _lastResetValueTemplate; }; #endif diff --git a/src/device-types/HASensorNumber.cpp b/src/device-types/HASensorNumber.cpp index 1f34627..b7591d1 100644 --- a/src/device-types/HASensorNumber.cpp +++ b/src/device-types/HASensorNumber.cpp @@ -25,12 +25,9 @@ bool HASensorNumber::setValue(const HANumeric& value, const bool force) return true; } - if (publishValue(value)) { - _currentValue = value; - return true; - } - - return false; + const bool published = publishValue(value); + _currentValue = value; + return published; } void HASensorNumber::onMqttConnected() diff --git a/src/device-types/HASwitch.cpp b/src/device-types/HASwitch.cpp index e7c3f0b..75201a9 100644 --- a/src/device-types/HASwitch.cpp +++ b/src/device-types/HASwitch.cpp @@ -2,7 +2,25 @@ #ifndef EX_ARDUINOHA_SWITCH #include "../HAMqtt.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" +#include + +static bool payloadEquals( + const uint8_t* data, + const uint16_t length, + const char* ram, + const __FlashStringHelper* flash +) +{ + if (ram) { + const size_t n = strlen(ram); + return (length == n) && (memcmp(data, ram, length) == 0); + } + + const size_t n = strlen_P(AHAFROMFSTR(flash)); + return (length == n) && (memcmp_P(data, AHAFROMFSTR(flash), n) == 0); +} HASwitch::HASwitch(const char* uniqueId) : HABaseDeviceType(AHATOFSTR(HAComponentSwitch), uniqueId), @@ -11,23 +29,36 @@ HASwitch::HASwitch(const char* uniqueId) : _retain(false), _optimistic(false), _currentState(false), + _payloadOn(nullptr), + _payloadOff(nullptr), + _stateOn(nullptr), + _stateOff(nullptr), + _valueTemplate(nullptr), + _commandTemplate(nullptr), _commandCallback(nullptr) { } +void HASwitch::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + +void HASwitch::setCommandTemplate(const char* commandTemplate) +{ + _commandTemplate = commandTemplate; +} + bool HASwitch::setState(const bool state, const bool force) { if (!force && state == _currentState) { return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HASwitch::buildSerializer() @@ -36,14 +67,39 @@ void HASwitch::buildSerializer() return; } - _serializer = new HASerializer(this, 12); // 12 - max properties nb + _serializer = new HASerializer(this, 24); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HADeviceClassProperty), _class); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadOn)) { + _serializer->set(AHATOFSTR(HAPayloadOnProperty), _payloadOn); + } + + if (nonEmptyString(_payloadOff)) { + _serializer->set(AHATOFSTR(HAPayloadOffProperty), _payloadOff); + } + + if (nonEmptyString(_stateOn)) { + _serializer->set(AHATOFSTR(HAStateOnDiscoveryProperty), _stateOn); + } + + if (nonEmptyString(_stateOff)) { + _serializer->set(AHATOFSTR(HAStateOffDiscoveryProperty), _stateOff); + } + + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + _serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + // optional property if (_retain) { _serializer->set( @@ -73,7 +129,7 @@ HASerializer* HASwitch::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 12); + HASerializer* serializer = new HASerializer(this, 24); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentSwitch), @@ -82,10 +138,35 @@ HASerializer* HASwitch::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HADeviceClassProperty), _class); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); + if (nonEmptyString(_payloadOn)) { + serializer->set(AHATOFSTR(HAPayloadOnProperty), _payloadOn); + } + + if (nonEmptyString(_payloadOff)) { + serializer->set(AHATOFSTR(HAPayloadOffProperty), _payloadOff); + } + + if (nonEmptyString(_stateOn)) { + serializer->set(AHATOFSTR(HAStateOnDiscoveryProperty), _stateOn); + } + + if (nonEmptyString(_stateOff)) { + serializer->set(AHATOFSTR(HAStateOffDiscoveryProperty), _stateOff); + } + + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_retain) { serializer->set( AHATOFSTR(HARetainProperty), @@ -132,8 +213,6 @@ void HASwitch::onMqttMessage( const uint16_t length ) { - (void)payload; - const bool hasCommandCallback = _commandCallback #if defined(ARDUINOHA_ENABLE_STDFUNCTION) @@ -146,7 +225,24 @@ void HASwitch::onMqttMessage( uniqueId(), AHATOFSTR(HACommandTopic) )) { - bool state = length == strlen_P(HAStateOn); + const bool isOn = payloadEquals( + payload, + length, + _payloadOn, + AHATOFSTR(HAStateOn) + ); + const bool isOff = payloadEquals( + payload, + length, + _payloadOff, + AHATOFSTR(HAStateOff) + ); + + if (!isOn && !isOff) { + return; + } + + const bool state = isOn; if (_commandCallback) { _commandCallback(state, this); } @@ -160,9 +256,33 @@ void HASwitch::onMqttMessage( bool HASwitch::publishState(const bool state) { + if (state) { + if (nonEmptyString(_stateOn)) { + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + _stateOn, + true + ); + } + + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + AHATOFSTR(HAStateOn), + true + ); + } + + if (nonEmptyString(_stateOff)) { + return publishOnDataTopic( + AHATOFSTR(HAStateTopic), + _stateOff, + true + ); + } + return publishOnDataTopic( AHATOFSTR(HAStateTopic), - AHATOFSTR(state ? HAStateOn : HAStateOff), + AHATOFSTR(HAStateOff), true ); } diff --git a/src/device-types/HASwitch.h b/src/device-types/HASwitch.h index 542c98d..cc61088 100644 --- a/src/device-types/HASwitch.h +++ b/src/device-types/HASwitch.h @@ -103,6 +103,21 @@ public: inline void setOptimistic(const bool optimistic) { _optimistic = optimistic; } + inline void setPayloadOn(const char* payload) + { _payloadOn = payload; } + + inline void setPayloadOff(const char* payload) + { _payloadOff = payload; } + + inline void setStateOn(const char* state) + { _stateOn = state; } + + inline void setStateOff(const char* state) + { _stateOff = state; } + + void setValueTemplate(const char* valueTemplate); + void setCommandTemplate(const char* commandTemplate); + /** * Registers callback that will be called each time the on/off command from HA is received. * Please note that it's not possible to register multiple callbacks for the same switch. @@ -168,6 +183,13 @@ private: /// The current state of the switch. By default it's `false`. bool _currentState; + const char* _payloadOn; + const char* _payloadOff; + const char* _stateOn; + const char* _stateOff; + const char* _valueTemplate; + const char* _commandTemplate; + /// The callback that will be called when switch command is received from the HA. HASWITCH_CALLBACK(_commandCallback); diff --git a/src/device-types/HAText.cpp b/src/device-types/HAText.cpp index 1d7d1fa..fc91148 100644 --- a/src/device-types/HAText.cpp +++ b/src/device-types/HAText.cpp @@ -2,6 +2,7 @@ #ifndef EX_ARDUINOHA_TEXT #include "../HAMqtt.h" +#include "../utils/HADictionary.h" #include "../utils/HASerializer.h" HAText::HAText(const char* uniqueId) : @@ -13,6 +14,8 @@ HAText::HAText(const char* uniqueId) : _minValue(), _maxValue(), _pattern(nullptr), + _valueTemplate(nullptr), + _commandTemplate(nullptr), _currentState(nullptr), _commandCallback(nullptr) #if defined(ARDUINOHA_ENABLE_STDFUNCTION) @@ -22,6 +25,16 @@ HAText::HAText(const char* uniqueId) : } +void HAText::setValueTemplate(const char* valueTemplate) +{ + _valueTemplate = valueTemplate; +} + +void HAText::setCommandTemplate(const char* commandTemplate) +{ + _commandTemplate = commandTemplate; +} + bool HAText::setState(const char* state, const bool force) { if (!state) { @@ -36,12 +49,9 @@ bool HAText::setState(const char* state, const bool force) return true; } - if (publishState(state)) { - _currentState = state; - return true; - } - - return false; + const bool published = publishState(state); + _currentState = state; + return published; } void HAText::buildSerializer() @@ -50,10 +60,11 @@ void HAText::buildSerializer() return; } - _serializer = new HASerializer(this, 15); // 15 - max properties nb + _serializer = new HASerializer(this, 22); _serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(_serializer); _serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(_serializer); _serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); _serializer->set(AHATOFSTR(HAIconProperty), _icon); _serializer->set( @@ -63,6 +74,14 @@ void HAText::buildSerializer() ); _serializer->set(AHATOFSTR(HAPatternProperty), _pattern); + if (nonEmptyString(_valueTemplate)) { + _serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + _serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_minValue.isSet()) { _serializer->set( AHATOFSTR(HAMinProperty), @@ -107,7 +126,7 @@ HASerializer* HAText::buildDeviceDiscoverySerializer() return nullptr; } - HASerializer* serializer = new HASerializer(this, 15); + HASerializer* serializer = new HASerializer(this, 22); serializer->set( AHATOFSTR(HAPlatformProperty), AHATOFSTR(HAComponentText), @@ -116,6 +135,7 @@ HASerializer* HAText::buildDeviceDiscoverySerializer() serializer->set(AHATOFSTR(HANameProperty), _name); setEntityIdProperty(serializer); serializer->set(HASerializer::WithUniqueId); + applyCommonEntityProperties(serializer); serializer->set(AHATOFSTR(HAStateEntityCategory), nonEmptyString(_entityCategory)); serializer->set(AHATOFSTR(HAIconProperty), _icon); serializer->set( @@ -125,6 +145,14 @@ HASerializer* HAText::buildDeviceDiscoverySerializer() ); serializer->set(AHATOFSTR(HAPatternProperty), _pattern); + if (nonEmptyString(_valueTemplate)) { + serializer->set(AHATOFSTR(HAValueTemplateProperty), _valueTemplate); + } + + if (nonEmptyString(_commandTemplate)) { + serializer->set(AHATOFSTR(HACommandTemplateProperty), _commandTemplate); + } + if (_minValue.isSet()) { serializer->set( AHATOFSTR(HAMinProperty), diff --git a/src/device-types/HAText.h b/src/device-types/HAText.h index 2d5cf32..567ae16 100644 --- a/src/device-types/HAText.h +++ b/src/device-types/HAText.h @@ -121,6 +121,9 @@ public: inline void setPattern(const char* pattern) { _pattern = pattern; } + void setValueTemplate(const char* valueTemplate); + void setCommandTemplate(const char* commandTemplate); + /** * Registers callback that will be called each time text command from HA is received. * Please note that it's not possible to register multiple callbacks for the same text entity. @@ -197,6 +200,9 @@ private: /// Regular expression pattern accepted by Home Assistant. It can be nullptr. const char* _pattern; + const char* _valueTemplate; + const char* _commandTemplate; + /// The current state of the text. It can be nullptr if state wasn't set. const char* _currentState; diff --git a/src/mocks/PubSubClientMock.cpp b/src/mocks/PubSubClientMock.cpp index e1c7727..bc17e9e 100644 --- a/src/mocks/PubSubClientMock.cpp +++ b/src/mocks/PubSubClientMock.cpp @@ -14,7 +14,8 @@ PubSubClientMock::PubSubClientMock() : _subscriptions(nullptr), _subscriptionsNb(0), _insideCallback(false), - _publishCallsFromCallbackNb(0), + _failNextBeginPublish(false), + _failNextEndPublish(false), callback(nullptr) { @@ -121,8 +122,8 @@ bool PubSubClientMock::beginPublish( return false; } - if (_insideCallback) { - _publishCallsFromCallbackNb++; + if (_failNextBeginPublish) { + _failNextBeginPublish = false; return false; } @@ -178,6 +179,11 @@ int PubSubClientMock::endPublish() return 0; } + if (_failNextEndPublish) { + _failNextEndPublish = false; + return 0; + } + size_t messageSize = _pendingMessage->bufferSize; uint8_t index = _flushedMessagesNb; diff --git a/src/mocks/PubSubClientMock.h b/src/mocks/PubSubClientMock.h index ca88f55..58daf30 100644 --- a/src/mocks/PubSubClientMock.h +++ b/src/mocks/PubSubClientMock.h @@ -169,11 +169,11 @@ public: inline bool isInsideCallback() const { return _insideCallback; } - inline uint16_t getPublishCallsFromCallbackNb() const - { return _publishCallsFromCallbackNb; } + inline void failNextBeginPublish() + { _failNextBeginPublish = true; } - inline void resetPublishCallsFromCallbackNb() - { _publishCallsFromCallbackNb = 0; } + inline void failNextEndPublish() + { _failNextEndPublish = true; } void clearFlushedMessages(); void clearSubscriptions(); @@ -194,7 +194,8 @@ private: MqttConnection _connection; MqttWill _lastWill; bool _insideCallback; - uint16_t _publishCallsFromCallbackNb; + bool _failNextBeginPublish; + bool _failNextEndPublish; MQTT_CALLBACK_SIGNATURE; }; diff --git a/src/utils/HAAvailabilityConfig.cpp b/src/utils/HAAvailabilityConfig.cpp new file mode 100644 index 0000000..e046c4b --- /dev/null +++ b/src/utils/HAAvailabilityConfig.cpp @@ -0,0 +1,194 @@ +#include +#include +#include "HAAvailabilityConfig.h" +#include "HADictionary.h" + +static uint16_t jsonEscapedStringSize(const char* s) +{ + if (!s) { + return 0; + } + return 2 * strlen_P(HASerializerJsonEscapeChar) + strlen(s); +} + +static void appendEscapedString(char* buf, const char* s) +{ + strcat_P(buf, HASerializerJsonEscapeChar); + strcat(buf, s); + strcat_P(buf, HASerializerJsonEscapeChar); +} + +HAAvailabilityConfig::HAAvailabilityConfig() : + _count(0) +{ + memset(_entries, 0, sizeof(_entries)); +} + +HAAvailabilityConfig::~HAAvailabilityConfig() +{ + clear(); +} + +char* HAAvailabilityConfig::duplicateString(const char* value) const +{ + if (!value || value[0] == '\0') { + return nullptr; + } + + const size_t len = strlen(value); + char* copy = new char[len + 1]; + memcpy(copy, value, len + 1); + return copy; +} + +void HAAvailabilityConfig::clearEntry(Entry& entry) +{ + delete[] entry.topic; + delete[] entry.valueTemplate; + delete[] entry.payloadAvailable; + delete[] entry.payloadNotAvailable; + entry.topic = nullptr; + entry.valueTemplate = nullptr; + entry.payloadAvailable = nullptr; + entry.payloadNotAvailable = nullptr; +} + +bool HAAvailabilityConfig::add( + const char* topic, + const char* valueTemplate, + const char* payloadAvailable, + const char* payloadNotAvailable +) +{ + if (!topic || topic[0] == '\0' || _count >= MaxEntries) { + return false; + } + + Entry entry = {}; + entry.topic = duplicateString(topic); + if (!entry.topic) { + return false; + } + + entry.valueTemplate = duplicateString(valueTemplate); + entry.payloadAvailable = duplicateString(payloadAvailable); + entry.payloadNotAvailable = duplicateString(payloadNotAvailable); + + _entries[_count] = entry; + _count++; + return true; +} + +void HAAvailabilityConfig::clear() +{ + for (uint8_t i = 0; i < _count; i++) { + clearEntry(_entries[i]); + } + + _count = 0; +} + +uint16_t HAAvailabilityConfig::calculateJsonSize() const +{ + uint16_t size = + strlen_P(HASerializerJsonArrayPrefix) + + strlen_P(HASerializerJsonArraySuffix); + + if (_count == 0) { + return size; + } + + size += (_count - 1) * strlen_P(HASerializerJsonPropertiesSeparator); + + for (uint8_t i = 0; i < _count; i++) { + const Entry& e = _entries[i]; + // {"t":"topic" ... } + size += strlen_P(HASerializerJsonDataPrefix); + size += strlen_P(HASerializerJsonDataSuffix); + + // "t":"..." + size += + strlen_P(HASerializerJsonPropertyPrefix) + + strlen_P(HATopic) + + strlen_P(HASerializerJsonPropertySuffix) + + jsonEscapedStringSize(e.topic); + + if (e.valueTemplate && e.valueTemplate[0] != '\0') { + size += strlen_P(HASerializerJsonPropertiesSeparator); + size += + strlen_P(HASerializerJsonPropertyPrefix) + + strlen_P(HAValueTemplateProperty) + + strlen_P(HASerializerJsonPropertySuffix) + + jsonEscapedStringSize(e.valueTemplate); + } + if (e.payloadAvailable && e.payloadAvailable[0] != '\0') { + size += strlen_P(HASerializerJsonPropertiesSeparator); + size += + strlen_P(HASerializerJsonPropertyPrefix) + + strlen_P(HAPayloadAvailableProperty) + + strlen_P(HASerializerJsonPropertySuffix) + + jsonEscapedStringSize(e.payloadAvailable); + } + if (e.payloadNotAvailable && e.payloadNotAvailable[0] != '\0') { + size += strlen_P(HASerializerJsonPropertiesSeparator); + size += + strlen_P(HASerializerJsonPropertyPrefix) + + strlen_P(HAPayloadNotAvailableProperty) + + strlen_P(HASerializerJsonPropertySuffix) + + jsonEscapedStringSize(e.payloadNotAvailable); + } + } + + return size; +} + +bool HAAvailabilityConfig::serialize(char* output) const +{ + if (!output) { + return false; + } + + output[0] = 0; + strcat_P(output, HASerializerJsonArrayPrefix); + + for (uint8_t i = 0; i < _count; i++) { + if (i > 0) { + strcat_P(output, HASerializerJsonPropertiesSeparator); + } + + strcat_P(output, HASerializerJsonDataPrefix); + + strcat_P(output, HASerializerJsonPropertyPrefix); + strcat_P(output, HATopic); + strcat_P(output, HASerializerJsonPropertySuffix); + appendEscapedString(output, _entries[i].topic); + + const Entry& e = _entries[i]; + if (e.valueTemplate && e.valueTemplate[0] != '\0') { + strcat_P(output, HASerializerJsonPropertiesSeparator); + strcat_P(output, HASerializerJsonPropertyPrefix); + strcat_P(output, HAValueTemplateProperty); + strcat_P(output, HASerializerJsonPropertySuffix); + appendEscapedString(output, e.valueTemplate); + } + if (e.payloadAvailable && e.payloadAvailable[0] != '\0') { + strcat_P(output, HASerializerJsonPropertiesSeparator); + strcat_P(output, HASerializerJsonPropertyPrefix); + strcat_P(output, HAPayloadAvailableProperty); + strcat_P(output, HASerializerJsonPropertySuffix); + appendEscapedString(output, e.payloadAvailable); + } + if (e.payloadNotAvailable && e.payloadNotAvailable[0] != '\0') { + strcat_P(output, HASerializerJsonPropertiesSeparator); + strcat_P(output, HASerializerJsonPropertyPrefix); + strcat_P(output, HAPayloadNotAvailableProperty); + strcat_P(output, HASerializerJsonPropertySuffix); + appendEscapedString(output, e.payloadNotAvailable); + } + + strcat_P(output, HASerializerJsonDataSuffix); + } + + strcat_P(output, HASerializerJsonArraySuffix); + return true; +} diff --git a/src/utils/HAAvailabilityConfig.h b/src/utils/HAAvailabilityConfig.h new file mode 100644 index 0000000..5362e8e --- /dev/null +++ b/src/utils/HAAvailabilityConfig.h @@ -0,0 +1,62 @@ +#ifndef AHA_HAAVAILABILITYCONFIG_H +#define AHA_HAAVAILABILITYCONFIG_H + +#include + +/** + * Holds up to four MQTT availability entries for discovery JSON (`avty`). + * Topic strings must remain valid for the lifetime of the config (same pattern as other ArduinoHA setters). + */ +class HAAvailabilityConfig +{ +public: + static const uint8_t MaxEntries = 4; + + struct Entry { + const char* topic; + const char* valueTemplate; + const char* payloadAvailable; + const char* payloadNotAvailable; + }; + + HAAvailabilityConfig(); + ~HAAvailabilityConfig(); + + /** + * Adds an availability entry. `topic` must be a full MQTT topic string. + * @return false when full or topic is null/empty. + */ + bool add( + const char* topic, + const char* valueTemplate = nullptr, + const char* payloadAvailable = nullptr, + const char* payloadNotAvailable = nullptr + ); + + void clear(); + + inline uint8_t count() const + { return _count; } + + inline const Entry& getEntry(const uint8_t index) const + { return _entries[index]; } + + /** + * Size of the JSON array only, including `[` and `]`, excluding property name and separators. + */ + uint16_t calculateJsonSize() const; + + /** + * Writes the JSON array into `output` (must be at least calculateJsonSize()+1 bytes). + */ + bool serialize(char* output) const; + +private: + void clearEntry(Entry& entry); + char* duplicateString(const char* value) const; + + Entry _entries[MaxEntries]; + uint8_t _count; +}; + +#endif diff --git a/src/utils/HADictionary.cpp b/src/utils/HADictionary.cpp index 2a9f2b0..36e05fe 100644 --- a/src/utils/HADictionary.cpp +++ b/src/utils/HADictionary.cpp @@ -83,7 +83,32 @@ const char HASwingModesProperty[] PROGMEM = {"swing_modes"}; const char HAModesProperty[] PROGMEM = {"modes"}; const char HATemperatureCommandTemplateProperty[] PROGMEM = {"temp_cmd_tpl"}; const char HAPayloadOnProperty[] PROGMEM = {"pl_on"}; +const char HAPayloadOffProperty[] PROGMEM = {"pl_off"}; +const char HAPayloadAvailableProperty[] PROGMEM = {"pl_avail"}; +const char HAPayloadNotAvailableProperty[] PROGMEM = {"pl_not_avail"}; +const char HAAvailabilityListProperty[] PROGMEM = {"avty"}; +const char HAAvailabilityModeProperty[] PROGMEM = {"avty_mode"}; const char HAExpireAfterProperty[] PROGMEM = {"exp_aft"}; +const char HAOffDelayProperty[] PROGMEM = {"off_dly"}; +const char HAEnabledByDefaultProperty[] PROGMEM = {"en"}; +const char HAEntityPictureProperty[] PROGMEM = {"ent_pic"}; +const char HAQosProperty[] PROGMEM = {"qos"}; +const char HAStateOnDiscoveryProperty[] PROGMEM = {"stat_on"}; +const char HAStateOffDiscoveryProperty[] PROGMEM = {"stat_off"}; +const char HASuggestedDisplayPrecisionProperty[] PROGMEM = {"sug_dsp_prc"}; +const char HAJsonAttributesTemplateProperty[] PROGMEM = {"json_attr_tpl"}; +const char HASensorOptionsProperty[] PROGMEM = {"ops"}; +const char HALastResetValueTemplateProperty[] PROGMEM = {"lrst_val_tpl"}; +const char HAPayloadResetProperty[] PROGMEM = {"pl_rst"}; +const char HAPayloadPressProperty[] PROGMEM = {"pl_prs"}; + +const char HADeviceModelIdProperty[] PROGMEM = {"mdl_id"}; +const char HADeviceHwVersionProperty[] PROGMEM = {"hw"}; +const char HADeviceSerialNumberProperty[] PROGMEM = {"sn"}; +const char HADeviceSuggestedAreaProperty[] PROGMEM = {"sa"}; +const char HADeviceViaDeviceProperty[] PROGMEM = {"via_device"}; +const char HADeviceConnectionsProperty[] PROGMEM = {"cns"}; +const char HAOriginSupportUrlProperty[] PROGMEM = {"url"}; // topics const char HAConfigTopic[] PROGMEM = {"config"}; diff --git a/src/utils/HADictionary.h b/src/utils/HADictionary.h index f324d4b..4ec2c71 100644 --- a/src/utils/HADictionary.h +++ b/src/utils/HADictionary.h @@ -83,7 +83,33 @@ extern const char HASwingModesProperty[]; extern const char HAModesProperty[]; extern const char HATemperatureCommandTemplateProperty[]; extern const char HAPayloadOnProperty[]; +extern const char HAPayloadOffProperty[]; +extern const char HAPayloadAvailableProperty[]; +extern const char HAPayloadNotAvailableProperty[]; +extern const char HAAvailabilityListProperty[]; +extern const char HAAvailabilityModeProperty[]; extern const char HAExpireAfterProperty[]; +extern const char HAOffDelayProperty[]; +extern const char HAEnabledByDefaultProperty[]; +extern const char HAEntityPictureProperty[]; +extern const char HAQosProperty[]; +extern const char HAStateOnDiscoveryProperty[]; +extern const char HAStateOffDiscoveryProperty[]; +extern const char HASuggestedDisplayPrecisionProperty[]; +extern const char HAJsonAttributesTemplateProperty[]; +extern const char HASensorOptionsProperty[]; +extern const char HALastResetValueTemplateProperty[]; +extern const char HAPayloadResetProperty[]; +extern const char HAPayloadPressProperty[]; + +// device (discovery `dev` object) +extern const char HADeviceModelIdProperty[]; +extern const char HADeviceHwVersionProperty[]; +extern const char HADeviceSerialNumberProperty[]; +extern const char HADeviceSuggestedAreaProperty[]; +extern const char HADeviceViaDeviceProperty[]; +extern const char HADeviceConnectionsProperty[]; +extern const char HAOriginSupportUrlProperty[]; // topics extern const char HAConfigTopic[]; diff --git a/src/utils/HASerializer.cpp b/src/utils/HASerializer.cpp index 832426d..0a34bf4 100644 --- a/src/utils/HASerializer.cpp +++ b/src/utils/HASerializer.cpp @@ -11,6 +11,7 @@ #include "../HAMqtt.h" #include "../utils/HAUtils.h" #include "../utils/HANumeric.h" +#include "../utils/HAAvailabilityConfig.h" #include "../device-types/HABaseDeviceType.h" uint16_t HASerializer::calculateConfigTopicLength( @@ -196,20 +197,9 @@ void HASerializer::set(const FlagType flag) entry->property = nullptr; entry->value = nullptr; } else if (flag == WithAvailability) { - HAMqtt* mqtt = HAMqtt::instance(); - const bool isSharedAvailability = mqtt->getDevice()->isSharedAvailabilityEnabled(); - const bool isAvailabilityConfigured = _deviceType->isAvailabilityConfigured(); - - if (!isSharedAvailability && !isAvailabilityConfigured) { - return; // not configured + if (_deviceType) { + _deviceType->configureAvailabilityEntries(this); } - - SerializerEntry* entry = addEntry(); - entry->type = TopicEntryType; - entry->property = AHATOFSTR(HAAvailabilityTopic); - entry->value = isSharedAvailability - ? mqtt->getDevice()->getAvailabilityTopic() - : nullptr; } } @@ -290,6 +280,9 @@ uint16_t HASerializer::calculateEntrySize(const SerializerEntry* entry) const case TopicEntryType: return calculateTopicEntrySize(entry); + case AvailabilityArrayEntryType: + return calculateAvailabilityArrayEntrySize(entry); + case FlagEntryType: return calculateFlagSize( static_cast(entry->subtype) @@ -332,6 +325,25 @@ uint16_t HASerializer::calculateTopicEntrySize( return size; } +uint16_t HASerializer::calculateAvailabilityArrayEntrySize( + const SerializerEntry* entry +) const +{ + if (!entry->value) { + return 0; + } + + const HAAvailabilityConfig* cfg = static_cast( + entry->value + ); + + return + strlen_P(HASerializerJsonPropertyPrefix) + + strlen_P(AHAFROMFSTR(entry->property)) + + strlen_P(HASerializerJsonPropertySuffix) + + cfg->calculateJsonSize(); +} + uint16_t HASerializer::calculateFlagSize(const FlagType flag) const { const HAMqtt* mqtt = HAMqtt::instance(); @@ -400,6 +412,11 @@ uint16_t HASerializer::calculatePropertyValueSize( return array->calculateSize(); } + case JsonLiteralPropertyValue: { + const char* value = static_cast(entry->value); + return value ? strlen(value) : 0; + } + default: return 0; } @@ -421,6 +438,9 @@ bool HASerializer::flushEntry(const SerializerEntry* entry) const case TopicEntryType: return flushTopic(entry); + case AvailabilityArrayEntryType: + return flushAvailabilityArray(entry); + case FlagEntryType: return flushFlag(entry); @@ -480,6 +500,16 @@ bool HASerializer::flushEntryValue(const SerializerEntry* entry) const return true; } + case JsonLiteralPropertyValue: { + const char* value = static_cast(entry->value); + if (!value) { + return false; + } + + mqtt->writePayload(value, strlen(value)); + return true; + } + default: return false; } @@ -523,6 +553,35 @@ bool HASerializer::flushTopic(const SerializerEntry* entry) const return true; } +bool HASerializer::flushAvailabilityArray(const SerializerEntry* entry) const +{ + HAMqtt* mqtt = HAMqtt::instance(); + if (!entry->value) { + return false; + } + + const HAAvailabilityConfig* cfg = static_cast( + entry->value + ); + + mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)); + mqtt->writePayload(entry->property); + mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix)); + + const uint16_t jsonSize = cfg->calculateJsonSize(); + if (jsonSize >= 512) { + return false; + } + + char buf[512]; + if (!cfg->serialize(buf)) { + return false; + } + + mqtt->writePayload(buf, jsonSize); + return true; +} + bool HASerializer::flushFlag(const SerializerEntry* entry) const { HAMqtt* mqtt = HAMqtt::instance(); diff --git a/src/utils/HASerializer.h b/src/utils/HASerializer.h index ea5a85e..b7b0cb5 100644 --- a/src/utils/HASerializer.h +++ b/src/utils/HASerializer.h @@ -23,7 +23,8 @@ public: UnknownEntryType = 0, PropertyEntryType, TopicEntryType, - FlagEntryType + FlagEntryType, + AvailabilityArrayEntryType }; /// The type of a flag for a FlagEntryType. @@ -40,7 +41,9 @@ public: ProgmemPropertyValue, BoolPropertyType, NumberPropertyType, - ArrayPropertyType + ArrayPropertyType, + /// Raw JSON fragment (no quoting); value is a null-terminated RAM string. + JsonLiteralPropertyValue }; /// Representation of a single entry in the object. @@ -198,6 +201,8 @@ public: */ bool flush() const; + friend class HABaseDeviceType; + private: /// Pointer to the device type that owns the serializer. HABaseDeviceType* _deviceType; @@ -229,6 +234,10 @@ private: */ uint16_t calculateTopicEntrySize(const SerializerEntry* entry) const; + uint16_t calculateAvailabilityArrayEntrySize( + const SerializerEntry* entry + ) const; + /** * Calculates the size of the entry of type `FlagEntryType`. */ @@ -261,6 +270,8 @@ private: */ bool flushTopic(const SerializerEntry* entry) const; + bool flushAvailabilityArray(const SerializerEntry* entry) const; + /** * Flushes the entry of type `FlagEntryType` to the MQTT. */ diff --git a/src/utils/HAUtils.cpp b/src/utils/HAUtils.cpp index a21eefc..1e8b607 100644 --- a/src/utils/HAUtils.cpp +++ b/src/utils/HAUtils.cpp @@ -3,6 +3,7 @@ #endif #include +#include #include "HAUtils.h" #include "HADictionary.h" @@ -46,3 +47,29 @@ char* HAUtils::byteArrayToStr( return dst; } + +uint8_t HAUtils::countSemicolonSeparatedOptions(const char* options) +{ + if (!options) { + return 0; + } + + uint8_t optionsNb = 1; + const uint16_t optionsLen = strlen(options); + + if (optionsLen == 0) { + return 0; + } + + for (uint16_t i = 0; i < optionsLen; i++) { + if (options[i] == ';') { + if (optionsNb == 255) { + break; + } + + optionsNb++; + } + } + + return optionsNb; +} diff --git a/src/utils/HAUtils.h b/src/utils/HAUtils.h index 630b204..5ed0b88 100644 --- a/src/utils/HAUtils.h +++ b/src/utils/HAUtils.h @@ -47,6 +47,12 @@ public: const byte* src, const uint16_t length ); + + /** + * Counts semicolon-separated segments (e.g. select/sensor options string). + * Empty string yields 0; a string without semicolons yields 1. + */ + static uint8_t countSemicolonSeparatedOptions(const char* options); }; #endif diff --git a/test/test_entities_basic/test_main.cpp b/test/test_entities_basic/test_main.cpp index 6b744fc..218150e 100644 --- a/test/test_entities_basic/test_main.cpp +++ b/test/test_entities_basic/test_main.cpp @@ -19,6 +19,7 @@ static TestCase tests[] = { TEST_ENTRY(test_BinarySensorTest_publish_initial_state), TEST_ENTRY(test_BinarySensorTest_publish_state_debounce), TEST_ENTRY(test_BinarySensorTest_publish_state_debounce_skip), + TEST_ENTRY(test_BinarySensorTest_disconnected_state_updates_local_shadow), TEST_ENTRY(test_BinarySensorTest_publish_state_off), TEST_ENTRY(test_BinarySensorTest_publish_state_on), TEST_ENTRY(test_ButtonTest_availability), @@ -57,6 +58,7 @@ static TestCase tests[] = { TEST_ENTRY(test_SensorNumberTest_publish_p3_zero_signed), TEST_ENTRY(test_SensorNumberTest_publish_p3_zero_unsigned), TEST_ENTRY(test_SensorNumberTest_publish_precision_mismatch), + TEST_ENTRY(test_SensorNumberTest_disconnected_value_updates_local_shadow), TEST_ENTRY(test_SensorNumberTest_publish_uint16), TEST_ENTRY(test_SensorNumberTest_publish_uint32), TEST_ENTRY(test_SensorNumberTest_publish_uint8), @@ -85,7 +87,7 @@ static TestCase tests[] = { TEST_ENTRY(test_SwitchTest_command_off), TEST_ENTRY(test_SwitchTest_command_on), TEST_ENTRY(test_SwitchTest_command_subscription), - TEST_ENTRY(test_SwitchTest_callback_publish_attempt_is_rejected), + TEST_ENTRY(test_SwitchTest_callback_publish_is_deferred_until_after_dispatch), TEST_ENTRY(test_SwitchTest_current_state_setter), TEST_ENTRY(test_SwitchTest_default_entity_id_setter), TEST_ENTRY(test_SwitchTest_default_params), @@ -121,7 +123,7 @@ static TestCase tests[] = { TEST_ENTRY(test_TextTest_publish_nothing_if_retained), TEST_ENTRY(test_TextTest_publish_state), TEST_ENTRY(test_TextTest_publish_state_debounce), - TEST_ENTRY(test_TextTest_callback_publish_attempt_is_rejected), + TEST_ENTRY(test_TextTest_callback_publish_is_deferred_until_after_dispatch), TEST_ENTRY(test_TextTest_retain_setter), }; diff --git a/test/test_entities_basic/test_main.h b/test/test_entities_basic/test_main.h index 5db6105..4937ff2 100644 --- a/test/test_entities_basic/test_main.h +++ b/test/test_entities_basic/test_main.h @@ -29,6 +29,7 @@ extern void test_BinarySensorTest_object_id_setter(void); extern void test_BinarySensorTest_publish_initial_state(void); extern void test_BinarySensorTest_publish_state_debounce(void); extern void test_BinarySensorTest_publish_state_debounce_skip(void); +extern void test_BinarySensorTest_disconnected_state_updates_local_shadow(void); extern void test_BinarySensorTest_publish_state_off(void); extern void test_BinarySensorTest_publish_state_on(void); extern void test_ButtonTest_availability(void); @@ -67,6 +68,7 @@ extern void test_SensorNumberTest_publish_p3_smaller(void); extern void test_SensorNumberTest_publish_p3_zero_signed(void); extern void test_SensorNumberTest_publish_p3_zero_unsigned(void); extern void test_SensorNumberTest_publish_precision_mismatch(void); +extern void test_SensorNumberTest_disconnected_value_updates_local_shadow(void); extern void test_SensorNumberTest_publish_uint16(void); extern void test_SensorNumberTest_publish_uint32(void); extern void test_SensorNumberTest_publish_uint8(void); @@ -113,7 +115,7 @@ extern void test_SwitchTest_publish_nothing_if_retained(void); extern void test_SwitchTest_publish_state_off(void); extern void test_SwitchTest_publish_state_on(void); extern void test_SwitchTest_retain_setter(void); -extern void test_SwitchTest_callback_publish_attempt_is_rejected(void); +extern void test_SwitchTest_callback_publish_is_deferred_until_after_dispatch(void); extern void test_TextTest_availability(void); extern void test_TextTest_command_callback(void); extern void test_TextTest_command_subscription(void); @@ -132,6 +134,6 @@ extern void test_TextTest_publish_nothing_if_retained(void); extern void test_TextTest_publish_state(void); extern void test_TextTest_publish_state_debounce(void); extern void test_TextTest_retain_setter(void); -extern void test_TextTest_callback_publish_attempt_is_rejected(void); +extern void test_TextTest_callback_publish_is_deferred_until_after_dispatch(void); #endif diff --git a/test/test_entities_basic/tests/test_binary_sensor.cpp b/test/test_entities_basic/tests/test_binary_sensor.cpp index a42e1d3..e100846 100644 --- a/test/test_entities_basic/tests/test_binary_sensor.cpp +++ b/test/test_entities_basic/tests/test_binary_sensor.cpp @@ -281,3 +281,13 @@ void test_BinarySensorTest_publish_state_debounce_skip(void) { TEST_ASSERT_TRUE(result); } +void test_BinarySensorTest_disconnected_state_updates_local_shadow(void) { + initMqttTest(testDeviceId) + + HABinarySensor sensor(testUniqueId); + + TEST_ASSERT_FALSE(sensor.setState(true)); + TEST_ASSERT_TRUE(sensor.getCurrentState()); + TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); +} + diff --git a/test/test_entities_basic/tests/test_sensor.cpp b/test/test_entities_basic/tests/test_sensor.cpp index 090fc23..068b87d 100644 --- a/test/test_entities_basic/tests/test_sensor.cpp +++ b/test/test_entities_basic/tests/test_sensor.cpp @@ -676,3 +676,14 @@ void test_SensorNumberTest_publish_precision_mismatch(void) { TEST_ASSERT_EQUAL(mock->getFlushedMessagesNb(), 0); } +void test_SensorNumberTest_disconnected_value_updates_local_shadow(void) { + initMqttTest(testDeviceId) + + HASensorNumber sensor(testUniqueId, HASensorNumber::PrecisionP1); + + TEST_ASSERT_FALSE(sensor.setValue(27.5f)); + TEST_ASSERT_TRUE(sensor.getCurrentValue().isSet()); + AHA_ASSERT_NEAR_FLOAT(27.5f, sensor.getCurrentValue().toFloat(), 0.1f); + TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); +} + diff --git a/test/test_entities_basic/tests/test_switch.cpp b/test/test_entities_basic/tests/test_switch.cpp index 1dd0bff..0c9d185 100644 --- a/test/test_entities_basic/tests/test_switch.cpp +++ b/test/test_entities_basic/tests/test_switch.cpp @@ -42,9 +42,9 @@ void onCommandReceived(bool state, HASwitch* caller) lastCommandCallbackCall.caller = caller; } -void onCommandPublishAttempt(bool state, HASwitch* caller) +void onCommandDeferredPublish(bool state, HASwitch* caller) { - TEST_ASSERT_FALSE(caller->setState(state)); + TEST_ASSERT_TRUE(caller->setState(state)); } void test_SwitchTest_invalid_unique_id(void) { @@ -393,17 +393,19 @@ void test_SwitchTest_command_off(void) { assertCommandCallbackCalled(false, &testSwitch) } -void test_SwitchTest_callback_publish_attempt_is_rejected(void) { +void test_SwitchTest_callback_publish_is_deferred_until_after_dispatch(void) { prepareTest mock->connectDummy(); + mqtt.resetDeferredPublishTestCounters(); HASwitch testSwitch(testUniqueId); - testSwitch.onCommand(onCommandPublishAttempt); + testSwitch.onCommand(onCommandDeferredPublish); mock->fakeMessage(AHATOFSTR(CommandTopic), F("ON")); - TEST_ASSERT_EQUAL(1, mock->getPublishCallsFromCallbackNb()); - TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + AHA_ASSERT_MQTT_MESSAGE(mock, 0, AHATOFSTR(StateTopic), "ON", true); } void test_SwitchTest_different_switch_command(void) { diff --git a/test/test_entities_basic/tests/test_text.cpp b/test/test_entities_basic/tests/test_text.cpp index 2dd3dba..013bca6 100644 --- a/test/test_entities_basic/tests/test_text.cpp +++ b/test/test_entities_basic/tests/test_text.cpp @@ -46,9 +46,9 @@ void onCommandReceived(const char* value, HAText* caller) lastCommandCallbackCall.caller = caller; } -void onCommandPublishAttempt(const char* value, HAText* caller) +void onCommandDeferredPublish(const char* value, HAText* caller) { - TEST_ASSERT_FALSE(caller->setState(value)); + TEST_ASSERT_TRUE(caller->setState(value)); } void test_TextTest_invalid_unique_id(void) { @@ -328,17 +328,19 @@ void test_TextTest_command_callback(void) { assertCommandCallbackCalled("hello", &text) } -void test_TextTest_callback_publish_attempt_is_rejected(void) { +void test_TextTest_callback_publish_is_deferred_until_after_dispatch(void) { prepareTest mock->connectDummy(); + mqtt.resetDeferredPublishTestCounters(); HAText text(testUniqueId); - text.onCommand(onCommandPublishAttempt); + text.onCommand(onCommandDeferredPublish); mock->fakeMessage(AHATOFSTR(CommandTopic), F("hello")); - TEST_ASSERT_EQUAL(1, mock->getPublishCallsFromCallbackNb()); - TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + AHA_ASSERT_MQTT_MESSAGE(mock, 0, AHATOFSTR(StateTopic), "hello", true); } void test_TextTest_different_text_command(void) { diff --git a/test/test_entities_numeric/test_main.cpp b/test/test_entities_numeric/test_main.cpp index 132e5f7..51f5c82 100644 --- a/test/test_entities_numeric/test_main.cpp +++ b/test/test_entities_numeric/test_main.cpp @@ -13,7 +13,7 @@ static TestCase tests[] = { TEST_ENTRY(test_NumberTest_command_number_unsigned), TEST_ENTRY(test_NumberTest_command_number_zero), TEST_ENTRY(test_NumberTest_command_subscription), - TEST_ENTRY(test_NumberTest_callback_publish_attempt_is_rejected), + TEST_ENTRY(test_NumberTest_callback_publish_is_deferred_until_after_dispatch), TEST_ENTRY(test_NumberTest_current_state_setter_getter_float), TEST_ENTRY(test_NumberTest_current_state_setter_getter_int16), TEST_ENTRY(test_NumberTest_current_state_setter_getter_int32), @@ -227,7 +227,7 @@ static TestCase tests[] = { TEST_ENTRY(test_SelectTest_publish_state_only), TEST_ENTRY(test_SelectTest_retain_setter), TEST_ENTRY(test_SelectTest_single_option), - TEST_ENTRY(test_SelectTest_callback_publish_attempt_is_rejected), + TEST_ENTRY(test_SelectTest_callback_publish_is_deferred_until_after_dispatch), }; static const size_t TEST_COUNT = sizeof(tests) / sizeof(tests[0]); diff --git a/test/test_entities_numeric/test_main.h b/test/test_entities_numeric/test_main.h index d2636a4..e253857 100644 --- a/test/test_entities_numeric/test_main.h +++ b/test/test_entities_numeric/test_main.h @@ -23,7 +23,7 @@ extern void test_NumberTest_command_number_signed(void); extern void test_NumberTest_command_number_unsigned(void); extern void test_NumberTest_command_number_zero(void); extern void test_NumberTest_command_subscription(void); -extern void test_NumberTest_callback_publish_attempt_is_rejected(void); +extern void test_NumberTest_callback_publish_is_deferred_until_after_dispatch(void); extern void test_NumberTest_current_state_setter_getter_float(void); extern void test_NumberTest_current_state_setter_getter_int16(void); extern void test_NumberTest_current_state_setter_getter_int32(void); @@ -237,6 +237,6 @@ extern void test_SelectTest_publish_state_none(void); extern void test_SelectTest_publish_state_only(void); extern void test_SelectTest_retain_setter(void); extern void test_SelectTest_single_option(void); -extern void test_SelectTest_callback_publish_attempt_is_rejected(void); +extern void test_SelectTest_callback_publish_is_deferred_until_after_dispatch(void); #endif diff --git a/test/test_entities_numeric/tests/test_number.cpp b/test/test_entities_numeric/tests/test_number.cpp index e55e746..947794d 100644 --- a/test/test_entities_numeric/tests/test_number.cpp +++ b/test/test_entities_numeric/tests/test_number.cpp @@ -42,9 +42,9 @@ void onCommandReceived(HANumeric number, HANumber* caller) lastCommandCallbackCall.caller = caller; } -void onCommandPublishAttempt(HANumeric value, HANumber* caller) +void onCommandDeferredPublish(HANumeric value, HANumber* caller) { - TEST_ASSERT_FALSE(caller->setState(value)); + TEST_ASSERT_TRUE(caller->setState(value)); } void test_NumberTest_invalid_unique_id(void) { @@ -1015,17 +1015,19 @@ void test_NumberTest_command_number_float_p3(void) { assertCommandCallbackCalled(HANumeric(-1.234f, 3), &number) } -void test_NumberTest_callback_publish_attempt_is_rejected(void) { +void test_NumberTest_callback_publish_is_deferred_until_after_dispatch(void) { prepareTest mock->connectDummy(); + mqtt.resetDeferredPublishTestCounters(); HANumber number(testUniqueId); - number.onCommand(onCommandPublishAttempt); + number.onCommand(onCommandDeferredPublish); mock->fakeMessage(AHATOFSTR(CommandTopic), F("1234")); - TEST_ASSERT_EQUAL(1, mock->getPublishCallsFromCallbackNb()); - TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + AHA_ASSERT_MQTT_MESSAGE(mock, 0, AHATOFSTR(StateTopic), "1234", true); } void test_NumberTest_command_number_invalid(void) { diff --git a/test/test_entities_numeric/tests/test_select.cpp b/test/test_entities_numeric/tests/test_select.cpp index 884cfdd..0bdb350 100644 --- a/test/test_entities_numeric/tests/test_select.cpp +++ b/test/test_entities_numeric/tests/test_select.cpp @@ -41,9 +41,9 @@ void onCommandReceived(int8_t index, HASelect* caller) lastCommandCallbackCall.caller = caller; } -void onCommandPublishAttempt(int8_t index, HASelect* caller) +void onCommandDeferredPublish(int8_t index, HASelect* caller) { - TEST_ASSERT_FALSE(caller->setState(index)); + TEST_ASSERT_TRUE(caller->setState(index)); } void test_SelectTest_invalid_unique_id(void) { @@ -482,18 +482,20 @@ void test_SelectTest_command_option_non_existing(void) { assertCommandCallbackNotCalled() } -void test_SelectTest_callback_publish_attempt_is_rejected(void) { +void test_SelectTest_callback_publish_is_deferred_until_after_dispatch(void) { prepareTest mock->connectDummy(); + mqtt.resetDeferredPublishTestCounters(); HASelect select(testUniqueId); select.setOptions("Option A;B;C"); - select.onCommand(onCommandPublishAttempt); + select.onCommand(onCommandDeferredPublish); mock->fakeMessage(AHATOFSTR(CommandTopic), F("B")); - TEST_ASSERT_EQUAL(1, mock->getPublishCallsFromCallbackNb()); - TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + AHA_ASSERT_MQTT_MESSAGE(mock, 0, AHATOFSTR(StateTopic), "B", true); } void test_SelectTest_different_select_command(void) { diff --git a/test/test_mqtt_core/test_main.cpp b/test/test_mqtt_core/test_main.cpp index 198e5c5..1bef41f 100644 --- a/test/test_mqtt_core/test_main.cpp +++ b/test/test_mqtt_core/test_main.cpp @@ -7,7 +7,13 @@ static TestCase tests[] = { TEST_ENTRY(test_MqttTest_reconnect_interval_default_value), TEST_ENTRY(test_MqttTest_reconnect_interval_setter), TEST_ENTRY(test_MqttTest_reconnect_interval_throttles_attempts), - TEST_ENTRY(test_MqttTest_publish_attempt_from_message_callback_is_rejected), + TEST_ENTRY(test_MqttTest_publish_from_message_callback_is_deferred_and_flushed), + TEST_ENTRY(test_MqttTest_deferred_publish_order_is_preserved), + TEST_ENTRY(test_MqttTest_processing_message_flag_only_wraps_inbound_dispatch), + TEST_ENTRY(test_MqttTest_streaming_publish_from_message_callback_is_deferred_and_flushed), + TEST_ENTRY(test_MqttTest_deferred_publish_is_kept_across_disconnect_and_retried_from_loop), + TEST_ENTRY(test_MqttTest_failed_deferred_flush_keeps_queue_and_retries_in_order), + TEST_ENTRY(test_MqttTest_mixed_deferred_publish_order_is_preserved), TEST_ENTRY(test_DeviceTest_default_unique_id), TEST_ENTRY(test_DeviceTest_unique_id_constructor_char), diff --git a/test/test_mqtt_core/test_main.h b/test/test_mqtt_core/test_main.h index e0c418b..242bcaf 100644 --- a/test/test_mqtt_core/test_main.h +++ b/test/test_mqtt_core/test_main.h @@ -17,7 +17,13 @@ extern void test_MqttTest_maximum_number_of_device_types(void); extern void test_MqttTest_reconnect_interval_default_value(void); extern void test_MqttTest_reconnect_interval_setter(void); extern void test_MqttTest_reconnect_interval_throttles_attempts(void); -extern void test_MqttTest_publish_attempt_from_message_callback_is_rejected(void); +extern void test_MqttTest_publish_from_message_callback_is_deferred_and_flushed(void); +extern void test_MqttTest_deferred_publish_order_is_preserved(void); +extern void test_MqttTest_processing_message_flag_only_wraps_inbound_dispatch(void); +extern void test_MqttTest_streaming_publish_from_message_callback_is_deferred_and_flushed(void); +extern void test_MqttTest_deferred_publish_is_kept_across_disconnect_and_retried_from_loop(void); +extern void test_MqttTest_failed_deferred_flush_keeps_queue_and_retries_in_order(void); +extern void test_MqttTest_mixed_deferred_publish_order_is_preserved(void); extern void test_DeviceTest_default_unique_id(void); extern void test_DeviceTest_unique_id_constructor_char(void); diff --git a/test/test_mqtt_core/tests/test_mqtt.cpp b/test/test_mqtt_core/tests/test_mqtt.cpp index 99c3c01..baea614 100644 --- a/test/test_mqtt_core/tests/test_mqtt.cpp +++ b/test/test_mqtt_core/tests/test_mqtt.cpp @@ -5,7 +5,9 @@ static const char* testDeviceId = "testDevice"; static const char* testUniqueId = "uniqueId"; static HAMqtt* activeMqtt = nullptr; +static PubSubClientMock* activeMock = nullptr; static bool messageCallbackCalled = false; +static bool processingFlagSeenDuringCallback = false; const char ComponentNameStr[] PROGMEM = {"componentName"}; @@ -22,7 +24,7 @@ protected: } }; -void onMessagePublishAttempt(const char* topic, const uint8_t* payload, uint16_t length) +void onMessageDeferredPublish(const char* topic, const uint8_t* payload, uint16_t length) { (void)topic; (void)payload; @@ -30,7 +32,78 @@ void onMessagePublishAttempt(const char* topic, const uint8_t* payload, uint16_t messageCallbackCalled = true; TEST_ASSERT_NOT_NULL(activeMqtt); - TEST_ASSERT_FALSE(activeMqtt->publish("testData/testDevice/echo", "1", false)); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/echo", "1", false)); +} + +void onMessageQueueOrderedPublishes(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/a", "1", false)); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/b", "2", false)); +} + +void onMessageInspectProcessingState(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + processingFlagSeenDuringCallback = activeMqtt->isProcessingMessage(); +} + +void onMessageDeferredStreamingPublish(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + TEST_ASSERT_TRUE(activeMqtt->beginPublish("testData/testDevice/stream", 2, false)); + activeMqtt->writePayload("O", 1); + activeMqtt->writePayload("K", 1); + TEST_ASSERT_TRUE(activeMqtt->endPublish()); +} + +void onMessageDeferredPublishThenDisconnect(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + TEST_ASSERT_NOT_NULL(activeMock); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/retry", "1", false)); + activeMock->disconnect(); +} + +void onMessageDeferredPublishesForFailedFlush(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/a", "1", false)); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/b", "2", false)); +} + +void onMessageMixedDeferredPublishesPreserveOrder(const char* topic, const uint8_t* payload, uint16_t length) +{ + (void)topic; + (void)payload; + (void)length; + + TEST_ASSERT_NOT_NULL(activeMqtt); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/a", "1", false)); + TEST_ASSERT_TRUE(activeMqtt->beginPublish("testData/testDevice/b", 1, false)); + activeMqtt->writePayload("2", 1); + TEST_ASSERT_TRUE(activeMqtt->endPublish()); + TEST_ASSERT_TRUE(activeMqtt->publish("testData/testDevice/c", "3", false)); } void test_MqttTest_maximum_number_of_device_types(void) @@ -84,7 +157,7 @@ void test_MqttTest_reconnect_interval_throttles_attempts(void) TEST_ASSERT_EQUAL_UINT16(2, mock->getConnectCallsNb()); } -void test_MqttTest_publish_attempt_from_message_callback_is_rejected(void) +void test_MqttTest_publish_from_message_callback_is_deferred_and_flushed(void) { PubSubClientMock* mock = new PubSubClientMock(); HADevice device(testDeviceId); @@ -95,14 +168,186 @@ void test_MqttTest_publish_attempt_from_message_callback_is_rejected(void) activeMqtt = &mqtt; messageCallbackCalled = false; - mock->resetPublishCallsFromCallbackNb(); - mqtt.onMessage(onMessagePublishAttempt); + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageDeferredPublish); mock->fakeMessage("testData/testDevice/input", "1"); TEST_ASSERT_TRUE(messageCallbackCalled); - TEST_ASSERT_EQUAL_UINT16(1, mock->getPublishCallsFromCallbackNb()); + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/echo", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("1", mock->getFlushedMessages()[0]->buffer); + + activeMqtt = nullptr; +} + +void test_MqttTest_deferred_publish_order_is_preserved(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageQueueOrderedPublishes); + + mock->fakeMessage("testData/testDevice/input", "x"); + + TEST_ASSERT_EQUAL_UINT16(2, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(2, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/a", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("1", mock->getFlushedMessages()[0]->buffer); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/b", mock->getFlushedMessages()[1]->topic); + TEST_ASSERT_EQUAL_STRING("2", mock->getFlushedMessages()[1]->buffer); + + activeMqtt = nullptr; +} + +void test_MqttTest_processing_message_flag_only_wraps_inbound_dispatch(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + processingFlagSeenDuringCallback = false; + TEST_ASSERT_FALSE(mqtt.isProcessingMessage()); + mqtt.onMessage(onMessageInspectProcessingState); + + mock->fakeMessage("testData/testDevice/input", "1"); + + TEST_ASSERT_TRUE(processingFlagSeenDuringCallback); + TEST_ASSERT_FALSE(mqtt.isProcessingMessage()); + + activeMqtt = nullptr; +} + +void test_MqttTest_streaming_publish_from_message_callback_is_deferred_and_flushed(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageDeferredStreamingPublish); + + mock->fakeMessage("testData/testDevice/input", "1"); + + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(0, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_FALSE(mqtt.hasDeferredFlushFailureForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/stream", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("OK", mock->getFlushedMessages()[0]->buffer); + + activeMqtt = nullptr; +} + +void test_MqttTest_deferred_publish_is_kept_across_disconnect_and_retried_from_loop(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + activeMock = mock; + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageDeferredPublishThenDisconnect); + + mock->fakeMessage("testData/testDevice/input", "1"); + + TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_TRUE(mqtt.hasDeferredFlushFailureForTest()); + TEST_ASSERT_TRUE(mqtt.didDeferredFlushFailDueToDisconnectForTest()); TEST_ASSERT_EQUAL_UINT8(0, mock->getFlushedMessagesNb()); + + mock->connectDummy(); + mqtt.loop(); + + TEST_ASSERT_EQUAL_UINT8(0, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_FALSE(mqtt.hasDeferredFlushFailureForTest()); + TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/retry", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("1", mock->getFlushedMessages()[0]->buffer); + + activeMock = nullptr; + activeMqtt = nullptr; +} + +void test_MqttTest_failed_deferred_flush_keeps_queue_and_retries_in_order(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageDeferredPublishesForFailedFlush); + mock->failNextEndPublish(); + + mock->fakeMessage("testData/testDevice/input", "1"); + + TEST_ASSERT_EQUAL_UINT16(2, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(2, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_TRUE(mqtt.hasDeferredFlushFailureForTest()); + TEST_ASSERT_TRUE(mqtt.didDeferredFlushFailAtEndPublishForTest()); + TEST_ASSERT_EQUAL_UINT8(0, mock->getFlushedMessagesNb()); + + mqtt.loop(); + + TEST_ASSERT_EQUAL_UINT8(0, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_FALSE(mqtt.hasDeferredFlushFailureForTest()); + TEST_ASSERT_EQUAL_UINT8(2, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/a", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("1", mock->getFlushedMessages()[0]->buffer); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/b", mock->getFlushedMessages()[1]->topic); + TEST_ASSERT_EQUAL_STRING("2", mock->getFlushedMessages()[1]->buffer); + + activeMqtt = nullptr; +} + +void test_MqttTest_mixed_deferred_publish_order_is_preserved(void) +{ + PubSubClientMock* mock = new PubSubClientMock(); + HADevice device(testDeviceId); + HAMqtt mqtt(mock, device); + mqtt.setDataPrefix("testData"); + mqtt.begin("testHost", "testUser", "testPass"); + mock->connectDummy(); + + activeMqtt = &mqtt; + mqtt.resetDeferredPublishTestCounters(); + mqtt.onMessage(onMessageMixedDeferredPublishesPreserveOrder); + + mock->fakeMessage("testData/testDevice/input", "1"); + + TEST_ASSERT_EQUAL_UINT16(3, mqtt.getDeferredPublishEnqueueCountForTest()); + TEST_ASSERT_EQUAL_UINT8(0, mqtt.getPendingDeferredPublishesForTest()); + TEST_ASSERT_EQUAL_UINT8(3, mock->getFlushedMessagesNb()); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/a", mock->getFlushedMessages()[0]->topic); + TEST_ASSERT_EQUAL_STRING("1", mock->getFlushedMessages()[0]->buffer); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/b", mock->getFlushedMessages()[1]->topic); + TEST_ASSERT_EQUAL_STRING("2", mock->getFlushedMessages()[1]->buffer); + TEST_ASSERT_EQUAL_STRING("testData/testDevice/c", mock->getFlushedMessages()[2]->topic); + TEST_ASSERT_EQUAL_STRING("3", mock->getFlushedMessages()[2]->buffer); activeMqtt = nullptr; }