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:
- Hosting a local network that sub-devices connect to.
- Sending each sub-device's data up to Nexalware.
- 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:
- Open Sketch → Include Library → Manage Library....
- Search for ESPAsyncWebServer (by lacamera, actively maintained fork) and click Install.
- Search for AsyncTCP (by dvarrel or ESP32Async) and click
Install, the dependency
ESPAsyncWebServerneeds 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.
#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.
#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:
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.
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
- The Sub-Device Contract - the exact messages every sub-device needs to send and receive.
- Master Reference (MicroPython) - the same three pieces, in MicroPython instead.
- Arduino IDE / C Language Reference - the base connect/subscribe/publish loop this page builds on.