diff --git a/README.md b/README.md index 50d9911..4b2ef62 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery ## Start with a sensor ```cpp +#if defined(ESP8266) #include +#elif defined(ESP32) +#include +#endif #include WiFiClient client; @@ -24,11 +28,11 @@ void setup() { // Your application connects Wi-Fi before MQTT begins. temperature.setName("Temperature"); temperature.setUnitOfMeasurement("°C"); - mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); + mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); // Connection work begins in mqtt.loop(). } void loop() { - mqtt.loop(); + mqtt.loop(); // Maintains MQTT and publishes discovery after connecting. // Call temperature.setValue(...) when your reading changes. } ``` diff --git a/docs/ESP8266-LINKER-WORKAROUND.md b/docs/ESP8266-LINKER-WORKAROUND.md new file mode 100644 index 0000000..0260da0 --- /dev/null +++ b/docs/ESP8266-LINKER-WORKAROUND.md @@ -0,0 +1,26 @@ +# ESP8266 Postmortem linker workaround + +The ESP8266 Arduino framework pin used by the DeviceFramework, WiFiManager, +and DFTE test projects is intentional: + +```ini +platform_packages = + platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3 +``` + +That upstream commit fixes Postmortem's large-jump failure, +[`dangerous relocation: j: cannot encode`](https://github.com/esp8266/Arduino/commit/521ae60a89e64bb0d1eb7a0b7addf620ced5cad3). +It changes the restart wrapper to use a relaxed jump and adds an EPC1 address +check. The failure is a framework linker/runtime-support issue, not an ArduinoHA +or application-source error. + +Keep this exact framework snapshot in ESP8266 environments that need the +maintained test contract. It is unrelated to ESP32, whose pioarduino platform +selects its framework and compiler as a unit. Do not replace the SHA with a +version range: remove or advance the pin only after an upstream release includes +the fix and the affected large firmware has compiled successfully. + +The corresponding ESP32 Core 3 pin and shared PlatformIO-cache recovery steps +are documented in [DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md). + +Back to the [documentation map](README.md). diff --git a/docs/README.md b/docs/README.md index b407e5f..70f63d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ | [Device & discovery](device-and-discovery.md) | `HADevice`, discovery modes, metadata, identifiers, and migration | | [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, logging, and footprint flags | | [Entities](entities.md) | Supported Home Assistant entity classes and the best matching example | +| [ESP8266 linker workaround](ESP8266-LINKER-WORKAROUND.md) | Exact framework pin for the Postmortem large-jump fix | | [Examples](../examples/README.md) | Guided PlatformIO projects and focused entity recipes | | [Compatibility baseline](compatibility.md) | Supported Home Assistant capabilities and fork-specific compatibility notes | diff --git a/docs/device-and-discovery.md b/docs/device-and-discovery.md index 3976c76..1ec06a8 100644 --- a/docs/device-and-discovery.md +++ b/docs/device-and-discovery.md @@ -40,8 +40,11 @@ Defaults: Override before `begin()` if needed: ```cpp -mqtt.setDiscoveryPrefix("myHaPrefix"); -mqtt.setDataPrefix("myDataPrefix"); +void configureTopicPrefixes() { + // Set both before mqtt.begin(...) publishes any discovery data. + mqtt.setDiscoveryPrefix("myHaPrefix"); + mqtt.setDataPrefix("myDataPrefix"); +} ``` ### Single-component vs device discovery @@ -106,13 +109,16 @@ The migration stage is held in RAM. If the board reboots before completion, star 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"); +void configureDeviceDiscovery() { + // These strings are borrowed, so keep literals or other long-lived storage. + 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()`**. diff --git a/docs/getting-started.md b/docs/getting-started.md index 26a2e72..b5e0e71 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,20 +40,24 @@ HADevice device(mac, sizeof(mac)); HAMqtt mqtt(client, device); void setup() { - Ethernet.begin(mac); - mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); + Ethernet.begin(mac); // Bring up the network Client before MQTT can connect. + mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); // Connection begins in mqtt.loop(). } void loop() { - Ethernet.maintain(); - mqtt.loop(); + Ethernet.maintain(); // Renews DHCP leases where the Ethernet library requires it. + mqtt.loop(); // Services MQTT connection, discovery, and entity traffic. } ``` ### ESP8266 / ESP32 (example) ```cpp +#if defined(ESP8266) #include +#elif defined(ESP32) +#include +#endif #include WiFiClient client; @@ -66,15 +70,18 @@ void setup() { device.setUniqueId(mac, sizeof(mac)); WiFi.begin("SSID", "password"); + // Keep this blocking wait only in a minimal sketch. Production firmware + // should retry, time out, or hand control to its provisioning flow. while (WiFi.status() != WL_CONNECTED) { delay(500); } + // begin() stores broker settings; mqtt.loop() performs connection and recovery. mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); } void loop() { - mqtt.loop(); + mqtt.loop(); // Service reconnects, subscriptions, and discovery publishing. } ``` diff --git a/docs/mqtt-usage.md b/docs/mqtt-usage.md index 651a937..5cd999a 100644 --- a/docs/mqtt-usage.md +++ b/docs/mqtt-usage.md @@ -2,21 +2,25 @@ ## Callbacks and tuning +The following snippets extend a sketch that already owns long-lived `HADevice`, `HAMqtt`, and entity objects. Configure callbacks, discovery metadata, and availability before `mqtt.begin(...)`; call the publishing snippets later from normal application logic. + `HAMqtt` supports optional callbacks and PubSubClient tuning: ```cpp +// These callbacks are registered before mqtt.begin(...). void onMessage(const char* topic, const uint8_t* payload, uint16_t length) { /* ... */ } void onConnected() { /* ... */ } void onDisconnected() { /* ... */ } void onStateChanged(HAMqtt::ConnectionState state) { /* ... */ } void setup() { + // A successful reconnect creates a new MQTT session, so onConnected runs again. mqtt.onMessage(onMessage); mqtt.onConnected(onConnected); mqtt.onDisconnected(onDisconnected); mqtt.onStateChanged(onStateChanged); - mqtt.setBufferSize(512); // default 256 - mqtt.setKeepAlive(60); // seconds, default 15 + mqtt.setBufferSize(512); // Increase only when a larger MQTT packet is required. + mqtt.setKeepAlive(60); // Seconds; the default is 15. mqtt.begin("192.168.1.50", "user", "pass"); } ``` @@ -27,6 +31,7 @@ Subscribe after each successful connection (for example in `onConnected`), becau ```cpp void onConnected() { + // MQTT subscriptions belong to this session and must be restored after reconnecting. mqtt.subscribe("my/custom/topic"); } ``` @@ -36,8 +41,11 @@ Handle payloads in `onMessage`. ## Publishing arbitrary payloads ```cpp -mqtt.publish("customTopic", "payload", false); // not retained -mqtt.publish("customTopic", "payload", true); // retained +// Call after mqtt.begin(...); retain controls whether the broker keeps this value. +void publishApplicationState() { + mqtt.publish("customTopic", "payload", false); // Not retained. + mqtt.publish("customTopic", "payload", true); // Retained. +} ``` ## Availability @@ -45,11 +53,14 @@ mqtt.publish("customTopic", "payload", true); // retained **Shared availability (recommended):** one availability topic for the whole device — works well with **Last Will** (LWT): ```cpp -device.enableSharedAvailability(); -device.setPayloadAvailable("up"); -device.setPayloadNotAvailable("down"); -device.enableLastWill(); // broker publishes offline when TCP drops -// device.setAvailability(false); // optional: start as offline +// Configure device-wide availability before mqtt.begin(...). +void configureSharedAvailability() { + 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/`. @@ -57,16 +68,22 @@ device.enableLastWill(); // broker publishes offline when TCP drops Custom per-entity payloads are supported: ```cpp -sensor.setPayloadAvailable("ready"); -sensor.setPayloadNotAvailable("lost"); +// Configure the relevant entity before mqtt.begin(...). +void configureSensorAvailability() { + 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 }}"); +// `sensor` is the entity whose availability depends on these external topics. +void configureExternalAvailability() { + sensor.setAvailabilityMode("all"); + sensor.addAvailabilityEntry("bridge/status"); + sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}"); +} ``` ## Discovery helpers by entity @@ -74,40 +91,53 @@ sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}"); 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"); +// `entity` is an entity type that supports these common discovery fields. +void configureCommonDiscovery(HABaseDeviceType& entity) { + 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"); +// `sensor` is a read-only HASensor instance. +void configureSensorPresentation(HASensor& sensor) { + 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 }}"); +// These names refer to the matching writable entity instances in the application. +void configureWritableEntities(HASwitch& mySwitch, + HANumber& myNumber, + HASelect& mySelect, + HAText& myText, + HAButton& myButton) { + 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) }}"); + myNumber.setPayloadReset("RESET"); + myNumber.setCommandTemplate("{{ value | float | round(1) }}"); -mySelect.setCommandTemplate("{{ value_json.choice }}"); -myText.setCommandTemplate("{{ value_json.text }}"); -myButton.setPayloadPress("PRESS"); + mySelect.setCommandTemplate("{{ value_json.choice }}"); + myText.setCommandTemplate("{{ value_json.text }}"); + myButton.setPayloadPress("PRESS"); +} ``` ## Compiler macros @@ -121,12 +151,9 @@ Defined in `ArduinoHADefines.h` or via build flags. lifecycle callbacks remain ordinary function pointers. - **`ARDUINOHA_DEBUG`** — enables ArduinoHA logging by default and sets the initial maximum verbosity to `Debug`. Without this flag, structured logs are compiled in but remain disabled until you call `arduinoHASetLogEnabled(true)`. -Structured logging is available through: +Structured logging is available through a long-lived sink: ```cpp -arduinoHASetLogEnabled(true); -arduinoHASetLogLevel(ArduinoHALogLevel::Trace); - class MyLogSink : public ArduinoHALogSink { public: void log(const ArduinoHALogMessage& msg) override { @@ -138,6 +165,15 @@ public: Serial.println(msg.text); } }; + +MyLogSink logSink; // The installed sink must outlive ArduinoHA logging. + +void setup() { + Serial.begin(115200); + arduinoHASetLogSink(&logSink); + arduinoHASetLogEnabled(true); + arduinoHASetLogLevel(ArduinoHALogLevel::Trace); +} ``` `arduinoHALog(...)` and `arduinoHALogf(...)` support subsystem-tagged messages such as `mqtt`, `discovery`, `availability`, `serializer`, `entity`, and `device`. Install a sink when you want those messages to join your application’s normal serial or structured log stream. diff --git a/examples/mqtt-advanced/mqtt-advanced.ino b/examples/mqtt-advanced/mqtt-advanced.ino index 2f815c0..adebefc 100644 --- a/examples/mqtt-advanced/mqtt-advanced.ino +++ b/examples/mqtt-advanced/mqtt-advanced.ino @@ -12,12 +12,14 @@ HAMqtt mqtt(client, device); void onMqttMessage(const char* topic, const uint8_t* payload, uint16_t length) { // This callback is called when message from MQTT broker is received. // Please note that you should always verify if the message's topic is the one you expect. - // For example: if (memcmp(topic, "myCustomTopic") == 0) { ... } + // For example: if (strcmp(topic, "myCustomTopic") == 0) { ... } Serial.print("New message on topic: "); Serial.println(topic); Serial.print("Data: "); - Serial.println((const char*)payload); + // MQTT payloads are length-delimited; they are not guaranteed to end in NUL. + Serial.write(payload, length); + Serial.println(); mqtt.publish("myPublishTopic", "hello"); } diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 102aedf..8ea53b4 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -8,6 +8,32 @@ required=( examples/README.md ) +check_cpp_fence_scope() { + local markdown="$1" + awk ' + function brace_delta(line, copy) { + copy = line + return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy) + } + /^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next } + in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next } + in_cpp { + line = $0 + sub(/^[[:space:]]+/, "", line) + if (depth == 0 && + (line ~ /^(if|for|while|switch)[[:space:]]*\(/ || + line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ || + line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ || + line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) { + printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr" + failed = 1 + } + depth += brace_delta($0) + } + END { exit failed } + ' "$markdown" +} + for path in "${required[@]}"; do [[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; } done @@ -22,7 +48,7 @@ while IFS= read -r -d '' markdown; do esac [[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; } done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown") -done < <(find "$root" -path "$root/.git" -prune -o -name '*.md' -type f -print0) +done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0) while IFS= read -r example; do for required in README.md platformio.ini; do @@ -34,4 +60,8 @@ while IFS= read -r example; do } done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort) +while IFS= read -r markdown; do + check_cpp_fence_scope "$markdown" +done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print) + echo "Documentation links and required files passed"