a-barta/esp-mcmax

2.1.0

Latest
uploaded 1 hour ago
The MCMAX component for ESP32 projects that use espressif idf

Readme

# esp-mcmax

ESP-IDF component that turns an ESP32 into an MCMAX node - an MQTT-speaking
endpoint that a paired Tango device server can proxy into a control system.
The component handles the boilerplate (Ethernet, MQTT, NVS-backed runtime
config, OTA, web-based provisioning) so application code only has to declare
its Tango-style attributes/commands and react to events.

> **MCMAX** is a thin convention on top of MQTT that lets a Tango device
> server discover, read, and write attributes and invoke commands on
> ESP32-class devices over a regular broker. This component is the device
> side of that contract.

## What you get

| Capability | Provided by |
|---|---|
| Ethernet bring-up (PHY init via `ethernet_init`) | `esp-mcmax-ethernet.c` |
| MQTT v5 client, topic layout, subscriptions | `esp-mcmax-mqtt.c` |
| Self-description JSON on connect (attributes & commands) | `esp-mcmax.c` |
| OTA firmware updates (HTTPS, metadata-driven) | `esp-mcmax-ota.c` |
| NVS-persisted runtime config (broker URL, creds, device name, OTA URL) | `esp-mcmax-nvs.c` |
| Configuration webserver (HTTP, or HTTPS with a supplied cert) for first-boot provisioning | `esp-mcmax-webserver.c` |
| Event-driven API for incoming writes/commands and outgoing publishes | `esp-mcmax.c` |

Requires **ESP-IDF >= 6.0**.

## Quick start

Working examples ship in [`examples/`](examples). Pick the one closest to
your use case and copy it as a starting point - both are buildable with
`idf.py build` from inside the example directory.

| Example | What it demonstrates |
|---|---|
| [`esp-mcmax-example`](examples/esp-mcmax-example) | One RW string attribute (`test_rw`), one RO string attribute (`test_ro`), one command (`toggle_led`) that drives an LED, plus a 1 Hz timer-driven publisher. The smallest end-to-end MCMAX node. |
| [`esp-mcmax-temperature`](examples/esp-mcmax-temperature) | A single-attribute sensor node publishing a periodic temperature reading - representative of read-only telemetry use cases. |

Pick names that don't collide with Tango built-ins. Tango devices already
expose `State` and `Status` attributes, so don't reuse those for your own
MCMAX attribute names - the bridge will refuse to register them.

## MQTT topic layout

All topics are scoped by the device name (`mcmax_name`, set via the web UI
and persisted in NVS):

| Topic | Direction | Purpose |
|---|---|---|
| `mc_max/<name>/firmware_name` | OUT (retain) | Project name from `esp_app_get_description()` |
| `mc_max/<name>/firmware_version` | OUT (retain) | Firmware version string |
| `mc_max/<name>/tango_attributes` | OUT (retain) | JSON array of attribute descriptions |
| `mc_max/<name>/tango_commands` | OUT (retain) | JSON array of command descriptions |
| `mc_max/<name>/mc_output/<attr>` | OUT (retain) | Attribute value publishes (via `mcmax_post`) |
| `mc_max/<name>/mc_input/<attr>` | IN | Attribute writes from the Tango bridge |
| `mc_max/<name>/command/<cmd>` | IN | Command invocations from the Tango bridge |
| `mc_max/<name>/reboot` | IN | Built-in framework command: any payload triggers `esp_restart()` after a 500 ms grace period. |

The four self-description topics are republished on every (re)connect with
`retain=1` so a late-subscribing bridge can reconstruct the device's API.

Application attributes (`mc_output/...`) are published at QoS 1 with
`retain=1`, so a bridge that subscribes later reads the last known value
instead of waiting for the next publish. Seeding them from an
`MCMAX_EVENT_CONNECTED` handler (see *Events* below) is still worthwhile
after a device restart, since the values the broker holds are the ones
from before the restart.

Per attribute, the publish QoS can be overridden with the `qos` field of
`tango_attribute_config_t` (`MCMAX_QOS_0` / `_1` / `_2`). Leaving it unset
selects the default.

Inbound topics are subscribed at QoS 1 for `mc_input/...` and QoS 2 for
`command/...` and `reboot`. Commands are not idempotent, so exactly-once
matters there; attribute writes are setpoints and tolerate a duplicate.
Note that delivery happens at `min(publisher QoS, subscriber QoS)`, so the
publishing side has to match for this to take effect.

## Public API

```c
esp_err_t mcmax_init(mcmax_config_t *cfg);
esp_err_t mcmax_post(mcmax_data_t *data);
extern esp_event_loop_handle_t mcmax_core_loop_handle;
ESP_EVENT_DECLARE_BASE(MCMAX_EVENT_BASE);
```

`mcmax_init` brings up NVS, Ethernet, OTA, the webserver, and the MQTT
client, in that order, and registers your `cfg.callback` as a handler for
`MCMAX_EVENT_DATA_IN` on `mcmax_core_loop_handle`. It blocks only as long
as those subsystems take to start; it does **not** wait for the broker
connection.

`mcmax_post` enqueues an `mcmax_data_t` (name + value, both nul-terminated
strings) onto the event loop. The MQTT publisher consumes from the loop
and pushes to the broker. Returns `ESP_OK` if the post enqueued, not if
the publish reached the broker. If the loop queue is full the call blocks
up to 1 s and then returns the queue's error.

### Events

Posted on `mcmax_core_loop_handle` with base `MCMAX_EVENT_BASE`:

| Event | When | Payload |
|---|---|---|
| `MCMAX_EVENT_CONNECTED` (3) | Every (re)connect to the broker, after subscriptions and self-description publishes | none |
| `MCMAX_EVENT_DATA_IN` (2) | An MQTT write or command arrives | `mcmax_data_t *` (name + value) |
| `MCMAX_EVENT_DATA_OUT` (1) | Internal - `mcmax_post` posts this for the publisher to consume | `mcmax_data_t *` |

`MCMAX_EVENT_CONNECTED` matters: pre-connect publishes are silently
dropped by `CONFIG_MQTT_SKIP_PUBLISH_IF_DISCONNECTED`, so any "seed every
attribute at boot" logic must run from this event, not from `app_main`:

```c
static void on_connected(void *a, esp_event_base_t b, int32_t id, void *d)
{
    seed_all_attribute_values();
}

ESP_ERROR_CHECK(esp_event_handler_instance_register_with(
    mcmax_core_loop_handle, MCMAX_EVENT_BASE,
    MCMAX_EVENT_CONNECTED, on_connected, NULL, NULL));
```

The handler also runs on every reconnect, so a broker outage that drops
the session re-seeds the readbacks automatically.

### Limits

| Constant | Value | Meaning |
|---|---|---|
| `MCMAX_MAX_TOPIC_SIZE`   | 32 | Max attribute/command name length |
| `MCMAX_MAX_PAYLOAD_SIZE` | 64 | Max value payload length |
| `STR_LEN`                | 64 | Max length of NVS-persisted config strings |

The internal event-loop queue is sized 10. Bursting >10 publishes faster
than the MQTT publisher can drain will block `mcmax_post` for up to 1 s.

## Runtime configuration

Broker URL, port, credentials, device name, and OTA endpoint are stored
in NVS under the namespace `mqtt_config`. On first boot - or any time the
device is unreachable - connect to the device's configuration webserver to
set them via a form. Values persist across reboots.

The webserver is served over plain HTTP on **port 80** and unauthenticated by
default. Two opt-in hardening steps:

- **Authentication** - set `MCMAX_WEBSERVER_PASSWORD` (*menuconfig -> Component
  config -> esp-mcmax*) to require HTTP Basic auth on `/` and `/submit`.
- **HTTPS** - supply a PEM certificate and matching private key through
  `mcmax_config_t.webserver_cert_pem` / `webserver_key_pem`; the webserver then
  runs over TLS on **port 443** instead. Provision the certificate per device
  yourself (e.g. a leaf signed by your own CA) - the component ships no key.

`POST /submit` is CSRF-protected with a token generated once per boot. Five
consecutive failed Basic-auth attempts lock the webserver for 60 seconds.

## OTA updates

The OTA task polls
`https://<firmware_server_url>:<firmware_server_port>/build/<project>.json`
every 10 s. When the advertised `ver` differs from the running firmware it
downloads `<project>-<ver>.bin`, flashes it and reboots. The on-the-wire
metadata format is documented in `esp-mcmax-ota.c`.

### Transport security

Firmware is fetched over **HTTPS**, with the server validated against the
ESP-IDF X.509 certificate bundle. Your firmware server must therefore present
a TLS certificate that chains to a publicly-trusted root CA (e.g. Let's
Encrypt) - a self-signed or private-CA certificate will fail the handshake.

For a closed lab network with no TLS, set `MCMAX_OTA_ALLOW_INSECURE_HTTP`
(*menuconfig -> Component config -> esp-mcmax*) to fall back to plain HTTP.
This is insecure - an on-path attacker can push arbitrary firmware - so leave
it off in production.

### Image authenticity

TLS authenticates the *transport*, not the firmware's origin. To guarantee the
device boots only images you signed, enable
`CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT` (or full Secure Boot V2) in your
project: `esp_https_ota` then verifies the image signature before the new
image is allowed to boot. Signing-key custody is the integrator's
responsibility; the component does not manage keys.

### Rollback

With `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE`, a freshly flashed image is kept
only once it reconnects to the MQTT broker; an image that never connects is
reverted by the bootloader on the next reboot.

## Adding the component to a project

In your project's `main/idf_component.yml`:

```yaml
dependencies:
  ABartalesi/esp-mcmax:
    version: ">=2.0.0"
```

Or, for in-tree development against a sibling checkout:

```yaml
dependencies:
  ABartalesi/esp-mcmax:
    version: "*"
    override_path: "../../../esp-mcmax"
```

Don't forget to add `esp_event` to your component's `REQUIRES` if you
register handlers on `mcmax_core_loop_handle`.

## Building & CI

```bash
cd examples/esp-mcmax-example
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
```

CI (`.gitlab-ci.yml`) builds `examples/esp-mcmax-example` against two IDF
images: `espressif/idf:v6.0.1` (pinned; gates the pipeline and publishes a
flashable artifact set) and `espressif/idf:latest` (`allow_failure` - an
early warning for upstream breakage). Pushing a version tag additionally
publishes the component to the ESP Component Registry.

## License

See [LICENSE](LICENSE).

Links

Supports all targets

Maintainer

  • Antonio Bartalesi <antonio.bartalesi@gmail.com>
To add this component to your project, run:

idf.py add-dependency "a-barta/esp-mcmax^2.1.0"

download archive

Stats

  • Archive size
    Archive size ~ 51.40 KB
  • Downloaded in total
    Downloaded in total 0 times
  • Downloaded this version
    This version: 0 times

Badge

a-barta/esp-mcmax version: 2.1.0
|