nexalwarenexalwaredocs
Device Orchestration

Master Reference (Arduino / C++)

Building the device that coordinates sub-devices locally and reports up to Nexalware, in C++.

A master is a normal Nexalware device. Everything in the Arduino IDE / C Language Reference still applies: connect, subscribe, publish status, using PubSubClient and ArduinoJson. This page covers only what's added on top, three things:

  1. Hosting a local network that sub-devices connect to.
  2. Sending each sub-device's data up to Nexalware.
  3. Relaying commands back down to the right sub-device.

Written in C++ here, for the Arduino IDE (or arduino-cli). If you'd rather run MicroPython instead, see the MicroPython version of this same page, same three pieces, same message shapes.

Install two more libraries

On top of PubSubClient and ArduinoJson (see the base Arduino reference if you haven't installed those yet), a master needs a local WebSocket server. ESP32's Arduino core ships an async TCP stack that both of the following build on:

  1. Open Sketch → Include Library → Manage Library....
  2. Search for ESPAsyncWebServer (by lacamera, actively maintained fork) and click Install.
  3. Search for AsyncTCP (by dvarrel or ESP32Async) and click Install, the dependency ESPAsyncWebServer needs on ESP32.
arduino-cli lib install "ESPAsyncWebServer"
arduino-cli lib install "AsyncTCP"

1. Run WiFi in two modes at once

Your master needs two WiFi connections running at the same time:

  • STA mode: connects to your real WiFi network, so the master can reach Nexalware over the internet.
  • AP mode: the master broadcasts its own small WiFi network, the one your sub-devices join.
wifi_setup.ino
#include <WiFi.h>

void startAp(const char* ssid, const char* password) {
  WiFi.softAP(ssid, password);
  Serial.print("Local network up: ");
  Serial.println(WiFi.softAPIP()); // 192.168.4.1 by default
}

bool connectSta(const char* ssid, const char* password, uint32_t timeoutMs = 15000) {
  WiFi.begin(ssid, password);
  uint32_t deadline = millis() + timeoutMs;
  while (WiFi.status() != WL_CONNECTED && millis() < deadline) delay(250);
  return WiFi.status() == WL_CONNECTED;
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_AP_STA); // both modes active at once, this is the key call
  startAp("my-master-ap", "a-strong-password");
  connectSta("your-ssid", "your-password");
  // ... continue with the MQTT connect logic from the base Arduino reference
}

WIFI_AP_STA is what makes both run at the same time on one radio. If connectSta fails (no internet available right now), your sub-devices can still connect to the AP and talk to the master. Controlling your sub-devices locally never depends on Nexalware being reachable, that's the whole point, see Device Orchestration for why.

2. Run a local WebSocket server for sub-devices to connect to

Your sub-devices connect outward to the master, the master never has to find them. This means there's nothing to discover, every sub-device just connects to the master's fixed address, 192.168.4.1, the moment it joins the AP network.

sub_device_server.ino
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

AsyncWebServer server(80);
AsyncWebSocket ws("/sub-device");

// One entry per connected sub-device, external_id is whatever the
// sub-device sent in its own `identify` message. Keyed by the socket's
// own client id so a disconnect can find its entry without needing the
// AsyncWebSocketClient pointer to still be valid.
struct SubDeviceInfo {
  String externalId;
  String name;
  JsonDocument state;
  JsonDocument describe;
  uint32_t clientId;
};
std::map<String, SubDeviceInfo> subDevices; // keyed by external_id

void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
               AwsEventType type, void* arg, uint8_t* data, size_t len) {
  if (type != WS_EVT_DATA) return;

  JsonDocument msg;
  if (deserializeJson(msg, data, len)) return; // malformed, ignore

  if (!msg["type"].is<const char*>()) return; // not a message this contract defines

  String msgType = msg["type"].as<String>();

  if (msgType == "identify") {
    String externalId = msg["id"].as<String>();
    SubDeviceInfo& sd = subDevices[externalId];
    sd.externalId = externalId;
    sd.name = msg["name"].as<String>();
    sd.clientId = client->id();
  } else if (msgType == "describe") {
    for (auto& kv : subDevices) {
      if (kv.second.clientId == client->id()) kv.second.describe = msg;
    }
  } else if (msgType == "state") {
    for (auto& kv : subDevices) {
      if (kv.second.clientId == client->id()) kv.second.state = msg;
    }
  }
  // health / result / verify follow the same "find by clientId, stash it"
  // pattern, omitted here for brevity, add them the same way.
}

void setupSubDeviceServer() {
  ws.onEvent(onWsEvent);
  server.addHandler(&ws);
  server.begin();
}

Every message your sub-devices send follows the exact shape in the Sub-Device Contract. This handler just reads the type field and stashes whatever came in, that's the entire local relay, everything below works off this same subDevices map.

Why key by client id, not just external_id

AsyncWebSocketClient* becomes invalid the moment a sub-device disconnects, but Nexalware still needs to know that sub-device's last known state. Keeping externalId as the map key (not the client pointer) means a sub-device's record survives a disconnect, clientId is only there to route an outbound command to the right live socket while one exists, see step 4 below.

3. Send sub-device data up to Nexalware

You already have a publishStatus() function from the base Arduino reference, sending your master's own status. Add one array to it, sub_devices, built from the map above:

publishStatus (extended)
void publishStatus() {
  JsonDocument doc;
  doc["device_id"] = NXW_DEVICE_ID;
  doc["uptime"] = millis() / 1000;
  doc["ts"] = time(nullptr);

  JsonArray subDeviceReports = doc["sub_devices"].to<JsonArray>();
  for (auto& kv : subDevices) {
    JsonObject report = subDeviceReports.add<JsonObject>();
    report["external_id"] = kv.second.externalId;
    if (kv.second.name.length()) report["name"] = kv.second.name;
    if (!kv.second.state.isNull()) report["state"] = kv.second.state.as<JsonObject>();
    if (!kv.second.describe.isNull()) report["capabilities"] = kv.second.describe.as<JsonObject>();
  }

  char buf[1024];
  size_t n = serializeJson(doc, buf);
  client.publish(NXW_TOPIC_STATUS, buf, n);
}

Nothing about your master's own status fields changes, sub_devices just rides alongside them in the same message. The moment this reaches Nexalware, each sub-device gets its own record, visible on the dashboard and through the API, automatically.

4. Relay commands down to the right sub-device

You already have an onMessage function from the base Arduino reference, handling commands sent to the master. Add one check at the top: if the command has a target field, it's meant for a sub-device, not the master itself.

onMessage (extended)
void onMessage(char* topic, byte* payload, unsigned int length) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, length)) return;

  if (doc["target"].is<const char*>()) {
    String target = doc["target"].as<String>();
    auto it = subDevices.find(target);
    if (it == subDevices.end()) return; // this sub-device isn't known right now

    JsonDocument cmdMsg;
    cmdMsg["type"] = "command";
    cmdMsg["cmd"] = doc["cmd"];
    if (doc["params"].is<JsonObject>()) cmdMsg["params"] = doc["params"];

    char buf[512];
    size_t n = serializeJson(cmdMsg, buf);
    ws.text(it->second.clientId, buf, n); // relays only to that one socket

    // If this sub-device declared verifiable: true in its describe
    // message, ask it to confirm, and treat that answer as final instead
    // of trusting result alone. See the contract page for why that
    // distinction matters.
    if (it->second.describe["verifiable"] | false) {
      JsonDocument verifyMsg;
      verifyMsg["type"] = "verify";
      verifyMsg["cmd"] = doc["cmd"];
      char vbuf[128];
      size_t vn = serializeJson(verifyMsg, vbuf);
      ws.text(it->second.clientId, vbuf, vn);
    }
    return;
  }

  // existing logic for commands aimed at the master itself, unchanged
}

Unlike the MicroPython version, ws.text(clientId, ...) here is a plain, synchronous call, ESPAsyncWebServer queues the send onto its own async task internally, so there's no equivalent of MicroPython's uasyncio.create_task() step to remember, the library already handles that for you.

Once this runs, the full loop works end to end: Nexalware sends a command with target set, the master looks up that sub-device's live socket, relays the command, and the sub-device's result (or verify answer, if it sent one) shows up in the very next publishStatus() call.

Where to go from here

On this page