relayx/device-cpp

0.1.2

Latest
uploaded 5 hours ago
RelayX C++ Device SDK for ESP32

readme

# RelayX Device SDK for C++ (ESP32)

Official C++ SDK for connecting ESP32 IoT devices to the RelayX platform over NATS.

## Installation

Add the following to your project's `main/idf_component.yml`:

```yaml
dependencies:
  relayx/device-cpp: ">=0.1.0"
```

Then in your `main/CMakeLists.txt`:

```cmake
idf_component_register(
    SRCS "main.cpp"
    INCLUDE_DIRS "."
    REQUIRES device-cpp nvs_flash esp_wifi
)
```

The component manager will automatically pull `device-cpp` and all its dependencies when you run `idf.py build`.

Requires ESP-IDF v5.0+ (tested on v6.0).

## Quick Start

```cpp
#include "relay/device.h"
#include "cJSON.h"

relay_device_config_t config = {
    .api_key = "ey...",
    .secret  = "SUAXXXXX...",       // NKey seed
    .mode    = RELAY_MODE_PRODUCTION
};

RelayDevice* device = new RelayDevice(config);

device->connect();

// Publish telemetry
device->telemetry.publish_number("temperature", 22.5);

// Listen for RPC calls
device->rpc.listen("ping", [](relay_rpc_request_t* req) {
    cJSON* resp = cJSON_CreateObject();
    cJSON_AddBoolToObject(resp, "pong", true);
    req->respond(resp);
    
    cJSON_Delete(resp);
});

// Listen for commands
device->command.listen("reboot", [](const cJSON* data) {
    esp_restart();
});

// Main loop
while (true) {
    device->process();
    vTaskDelay(pdMS_TO_TICKS(10));
}

device->disconnect();
```

## Configuration

```cpp
relay_device_config_t config = {
    .api_key = "...",   // API Key
    .secret  = "...",   // Secret
    .mode    = RELAY_MODE_PRODUCTION  // or RELAY_MODE_TEST
};
```

- **api_key**: Your API Key to connect to the RelayX network
- **secret**: Your Secret to connect to the RelayX network
- **mode**: `RELAY_MODE_PRODUCTION` for when device is in production mode. `RELAY_MODE_TEST` for when device is in test mode.

## Functionality

### Connection

```cpp
RelayDevice* device = new RelayDevice(config);

// Listen for connection status changes
device->connection.on_status([](relay_connection_status_t status) {
    switch (status) {
        case RELAY_STATUS_CONNECTED:    // Initial connection established
        case RELAY_STATUS_DISCONNECTED: // Connection lost
        case RELAY_STATUS_RECONNECTING: // Attempting to reconnect
        case RELAY_STATUS_RECONNECTED:  // Reconnection successful
    }
});

relay_err_t err = device->connect();
if (err != RELAY_OK) {
    // Handle error
}

// Must be called in a loop - pumps NATS, fires callbacks, handles reconnect
while (true) {
    device->process();
    vTaskDelay(pdMS_TO_TICKS(10));
}

device->disconnect();
```

The SDK automatically reconnects when the connection drops. Telemetry, events, and logs published while offline are queued in an internal buffer and flushed once the connection is back. RPC and command listeners you registered before the disconnect are re-bound automatically — you do not need to call `listen()` again after a reconnect.

### Telemetry

Fire-and-forget sensor data publishing. Readings are validated against the device schema fetched on connect.

```cpp
// Number
device->telemetry.publish_number("temperature", 22.5);

// String
device->telemetry.publish_string("status", "running");

// Boolean
device->telemetry.publish_bool("motor_on", true);

// JSON (pass a cJSON object - encoded as native msgpack, not stringified)
cJSON* obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "x", 1.0);
cJSON_AddNumberToObject(obj, "y", 2.0);
device->telemetry.publish_json("position", obj);

cJSON_Delete(obj);
```

Each reading is automatically timestamped using the server-synced SNTP clock. Payload format: `{value: <typed>, timestamp: <ms>}`.

If the device schema is loaded, the SDK validates that the metric name exists and the type matches. Unknown metrics or type mismatches return `RELAY_ERR_VALIDATION`. Schema fetch failure is non-fatal - validation is skipped.

### Remote Procedure Calls (RPC)

Register handlers for incoming RPC calls.

```cpp
device->rpc.listen("get_status", [](relay_rpc_request_t* req) {
    // req->payload contains the JSON request data
    cJSON* resp = cJSON_CreateObject();
    cJSON_AddNumberToObject(resp, "uptime", 12345);
    cJSON_AddNumberToObject(resp, "heap_free", esp_get_free_heap_size());
    req->respond(resp);
    
    cJSON_Delete(resp);
});

// Error response
device->rpc.listen("dangerous_op", [](relay_rpc_request_t* req) {
    cJSON* err = cJSON_CreateObject();
    cJSON_AddStringToObject(err, "message", "not allowed");
    req->error(err);
    
    cJSON_Delete(err);
});

// Unregister
device->rpc.off("get_status");
```

Registering a duplicate listener returns `RELAY_ERR_DUPLICATE_LISTENER`.

### Commands

One-way commands from server to device. Incoming msgpack payload is automatically decoded to `cJSON*`.

```cpp
device->command.listen("reboot", [](const cJSON* data) {
    // data is a decoded cJSON object (do NOT delete it)
    char* str = cJSON_PrintUnformatted(data);
    ESP_LOGI("app", "Reboot command: %s", str);
    free(str);
    esp_restart();
});

// Unregister
device->command.off("reboot");
```

### Config

Get and set device configuration on the server. Uses core NATS request/reply with one-shot callbacks.

```cpp
// Get config
device->config_mgr.get([](relay_err_t err, const char* data, size_t len) {
    if (err == RELAY_OK) {
        ESP_LOGI("app", "Config: %.*s", (int)len, data);
    }
});

// Set config
cJSON* new_config = cJSON_CreateObject();
cJSON_AddNumberToObject(new_config, "sample_rate", 10);
device->config_mgr.set(new_config, [](relay_err_t err) {
    if (err == RELAY_OK) {
        ESP_LOGI("app", "Config updated");
    }
});
cJSON_Delete(new_config);
```

### Events

Fire-and-forget event publishing of events (fault codes, etc)

```cpp
device->event.send_number("battery_low", 15.0);
device->event.send_string("alert", "door_opened");
device->event.send_bool("motion_detected", true);

cJSON* obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "lat", 37.7749);
cJSON_AddNumberToObject(obj, "lng", -122.4194);
device->event.send_json("location", obj);
cJSON_Delete(obj);
```

### Logs

Structured device logging with three levels (`info`, `warn`, `error`).
Each call is **printf-style C-variadic** — pass a `const char* fmt` and
its arguments. Values are formatted into a per-call stack buffer (caps
at `RELAY_LOG_ENTRY_MAX_LEN`, default 256 bytes; excess is truncated).

```cpp
device->log.info("device booted, heap=%u", (unsigned)esp_get_free_heap_size());
device->log.info("temperature reading %.2f", 22.5);
device->log.warn("disk usage above threshold (%d%%)", 87);
device->log.error("parse failed code=0x%04x", err_code);
```

Each call also mirrors to ESP-IDF's logger (`ESP_LOGI` / `ESP_LOGW` /
`ESP_LOGE` under tag `relay-log`) so you keep your normal serial-monitor
dev experience.

**Thread-safety**: safe to call from any FreeRTOS task. The mutex
that protects the publisher queue lives inside `RelayTransport`, so
producers across multiple tasks can call `device->log.*` concurrently
without external synchronization. **Not safe from ISRs** — `xSemaphoreTake`
from interrupt context is undefined behavior.

**Delivery**: each entry is queued on the transport's async publisher
(see [Async Publishing](#async-publishing) below). When offline, entries
accumulate in the queue and drain automatically on reconnect, subject
to backpressure (`DROP_OLDEST` by default).

**Timestamp**: pulled from `TimeManager::now()` — wallclock-ms once SNTP
syncs, milliseconds-since-boot before that. Pre-sync logs render as
1970-relative timestamps in the App SDK.

### Time

Uses ESP-IDF's built-in SNTP with `time.google.com`. System clock is set automatically on connect.

```cpp
// Automatically initialized on device->connect()

// Milliseconds since Unix epoch
int64_t ms = device->time.now();

// Seconds since Unix epoch
int64_t sec = device->time.now_seconds();
```

`time.now()` does NOT require an active connection - it uses the system clock.

## Error Handling

All SDK operations return `relay_err_t` error codes. No C++ exceptions.

| Error Code | Description |
|---|---|
| `RELAY_OK` | Success |
| `RELAY_ERR_BUFFERED` | Deprecated. Kept for ABI compatibility, no longer returned by any API. |
| `RELAY_ERR_NOT_CONNECTED` | Operation requires active connection |
| `RELAY_ERR_TIMEOUT` | Request/reply timed out |
| `RELAY_ERR_VALIDATION` | Invalid metric name, schema type mismatch, or bad subject token |
| `RELAY_ERR_DUPLICATE_LISTENER` | Listener with this name already registered |
| `RELAY_ERR_CONNECT_FAILED` | NATS connection failed |
| `RELAY_ERR_PUBLISH_FAILED` | JetStream publish failed |
| `RELAY_ERR_SUBSCRIBE_FAILED` | Subscription creation failed |
| `RELAY_ERR_BUFFER_FULL` | Publish queue full (only surfaced under `RELAY_PUBLISH_BACKPRESSURE=1`) |
| `RELAY_ERR_INVALID_CONFIG` | Missing required config fields |
| `RELAY_ERR_PAYLOAD_TOO_LARGE` | Message exceeds `RELAY_PUBLISH_PAYLOAD_SIZE` |
| `RELAY_ERR_INTERNAL` | SDK internal failure (e.g. RTOS primitive could not be allocated on connect) |

## Async Publishing

`telemetry.publish_*`, `event.send_*`, and `log.info/warn/error` do not publish synchronously. Each call:

1. Encodes the payload (msgpack).
2. Copies it into a fixed-size slot in a shared payload pool.
3. Posts a reference onto a single FreeRTOS queue.
4. Returns immediately.

A dedicated background task (`relay_publisher`) drains the queue and issues JetStream publishes. While offline, the task blocks on a connection event group; entries accumulate in the queue and flush automatically once the link comes back.

The shared queue is sized by `RELAY_PUBLISH_QUEUE_SIZE` (default 64 entries) with each slot up to `RELAY_PUBLISH_PAYLOAD_SIZE` bytes (default 512). When both fill, the default behavior is **drop-oldest** — the queue head is evicted to make room for the new entry, and the call returns `RELAY_OK`. Set `RELAY_PUBLISH_BACKPRESSURE=1` to switch to **drop-newest**, where new calls return `RELAY_ERR_BUFFER_FULL` when full and the existing queue is preserved.

Failed publishes are retried up to `RELAY_PUBLISH_RETRY_COUNT` times (default 3) with backoff 100/500/2000 ms. On `device->disconnect()` the SDK waits up to `RELAY_PUBLISH_DRAIN_TIMEOUT_MS` (default 2000 ms) for the queue to empty before tearing down.

The publisher infrastructure (queue, payload pool, task stack, sync primitives) costs roughly 44 KB of heap and is allocated once on the first successful `connect()`. It is reused across reconnects, so any backlog accumulated while offline is preserved.

`publish_async` is safe to call from any FreeRTOS task. It is **not** safe to call from an ISR.

## Offline Behavior

- **Telemetry, Events, and Logs**: Queued in the shared async publisher. Flushed automatically on reconnect.
- **RPC & Commands**: Listener identity (slot, callback, name) survives reconnects; the SDK re-issues NATS subscriptions for you.
- **Config**: Returns `RELAY_ERR_NOT_CONNECTED` when offline (uses request/reply, not the async path).
- **Publish returns**: `RELAY_OK` once enqueued — whether the wire is up or down.

## Compile-Time Configuration

Override any default before including `relay/device.h`:

```cpp
#define RELAY_LOG_LEVEL 0                 // 0=NONE, 1=ERROR, 2=WARN, 3=INFO (default), 4=DEBUG
#define RELAY_PUBLISH_QUEUE_SIZE 128      // Max queued publishes (default 64)
#define RELAY_PUBLISH_PAYLOAD_SIZE 1024   // Max bytes per queued payload (default 512)
#define RELAY_PUBLISH_BACKPRESSURE 1      // 0=drop oldest (default), 1=drop newest
#define RELAY_PUBLISH_RETRY_COUNT 5       // Per-publish retry attempts (default 3)
#define RELAY_RECONNECT_INTERVAL_MS 5000  // Reconnect attempt interval (default 3000)
```

Disable subsystems you don't need to save RAM:

```cpp
#define RELAY_ENABLE_EVENT 0
#define RELAY_ENABLE_COMMAND 0
#define RELAY_ENABLE_RPC 0
#define RELAY_ENABLE_CONFIG 0
```

Minimum required: `RELAY_ENABLE_TELEMETRY` + `RELAY_ENABLE_CONNECTION` (always on).

## Testing

```bash
cd test
idf.py set-target esp32
idf.py build && idf.py -p /dev/tty.usbserial-110 flash monitor
```

The test suite uses ESP-IDF's Unity framework and runs on-device. It covers all internal utilities (subject builder, payload pool, listener registry, msgpack encoder/decoder, JWT parser, callback dispatch) and integration tests against the live server.

## License

Apache-2.0

Links

Supports all targets

License: Apache-2.0

To add this component to your project, run:

idf.py add-dependency "relayx/device-cpp^0.1.2"

download archive

Stats

  • Archive size
    Archive size ~ 115.74 KB
  • Downloaded in total
    Downloaded in total 1 time
  • Weekly Downloads Weekly Downloads (All Versions)
  • Downloaded this version
    This version: 0 times

Badge

relayx/device-cpp version: 0.1.2
|