Command Format
Every message sent from the CeresFlows backend to your controller is a command. This chapter defines the exact format of those commands. Your firmware must be able to parse both supported formats.
Two Command Formats
CeresFlows supports two command formats. Both must be supported in your firmware. The backend may use either one depending on the command type and configuration.
| Format | Example | Used for |
|---|---|---|
| JSON (external interface) | {"cmd":"ANALOG","id":1,"args":["A0"]} | All commands |
| Pipe (optimized core) | ANALOG|A0 | Simple commands — used internally for speed |
JSON Protocol
Structure
{
"cmd": "COMMAND_NAME",
"id": 42,
"args": ["param1", "param2"],
"namedParam1": "value",
"namedParam2": 100
}
| Field | Type | Required | Description |
|---|---|---|---|
cmd | string | ✅ Yes | Command name (uppercase, e.g. "ANALOG") |
id | integer | ❌ No | Request ID — echoed back in the response for correlation |
args | array | Depends on command | Positional arguments |
| Additional fields | any | Depends on command | Named parameters (used by I2C, UART, Modbus, SPI) |
Simple Command (with args)
{"cmd": "ANALOG", "id": 1, "args": ["A0"]}
{"cmd": "DIGITAL_WRITE", "id": 2, "args": ["D5", "1"]}
{"cmd": "DHT_READ", "id": 3, "args": ["D4"]}
System Command (no args)
{"cmd": "PING"}
{"cmd": "INFO", "id": 99}
{"cmd": "RESET"}
Complex Command (named parameters)
For bus commands (I2C_TRANSFER, UART_TRANSFER, MODBUS_RTU, SPI_TRANSFER), parameters are passed as named top-level fields — not in args:
{
"cmd": "I2C_TRANSFER",
"id": 10,
"addr": "0x44",
"write": [36, 14],
"wait": 15,
"len": 6
}
{
"cmd": "MODBUS_RTU",
"id": 11,
"slaveId": 1,
"funcCode": 3,
"startAddr": 0,
"len": 2,
"baudRate": 9600,
"rxPin": 10,
"txPin": 11
}
[!NOTE] Bus commands (
I2C_TRANSFER,UART_TRANSFER,MODBUS_RTU,SPI_TRANSFER) always use named parameters — never positionalargs. This is intentional: the JSON object is passed directly to the handler function which reads each field by name.
Pipe Protocol (Optimized Core)
The Pipe protocol encodes commands as a single string with | as separator. It is the internal routing format for all simple commands — the firmware converts JSON args into Pipe internally before dispatching to command handlers.
Structure
COMMAND_NAME|param1|param2|param3
Examples
PING
ANALOG|A0
DIGITAL_WRITE|D5|1
DHT_READ|D4
ULTRASONIC_TRIG_ECHO|D9|D10
Rules
- The command name comes first
- Parameters follow, separated by
| - No spaces around
| - No request ID in Pipe format (responses will not contain an
idfield)
[!WARNING] The Pipe protocol does not support request IDs. If your application requires request correlation (matching a specific response to a specific request), use the JSON protocol with the
idfield.
[!NOTE] Bus commands (
I2C_TRANSFER,MODBUS_RTU, etc.) cannot be expressed in Pipe format due to their complex parameter structure. The backend always sends these commands as JSON.
Request ID (id)
The id field is an integer that the backend attaches to a request. The firmware echoes it back unchanged in the response. This allows the backend to match responses to requests when multiple commands are in flight.
Request:
{"cmd": "ANALOG", "id": 42, "args": ["A0"]}
Response:
{"ok": 1, "value": 512, "id": 42}
If no id is sent, no id appears in the response:
{"cmd": "PING"} → {"ok": 1, "pong": 1}
Implementing ID Echo in Firmware
String processCommand(String input) {
input.trim();
if (input.length() == 0) return "";
char cmdBuf[32] = {0};
char paramsBuf[512] = {0};
String reqId = "";
if (input.charAt(0) == '{') {
// === JSON Protocol ===
StaticJsonDocument<1024> doc;
if (deserializeJson(doc, input)) return "{\"ok\":0,\"error\":\"ERR_JSON_PARSE\"}";
const char* cmd = doc["cmd"];
if (!cmd) return "{\"ok\":0,\"error\":\"ERR_MISSING_CMD\"}";
strncpy(cmdBuf, cmd, sizeof(cmdBuf) - 1);
// Capture request ID if present
if (doc.containsKey("id")) {
reqId = String(doc["id"].as<int>());
}
// ... parse args or named params ...
} else {
// === Pipe Protocol ===
int pipeIdx = input.indexOf('|');
if (pipeIdx > 0) {
input.substring(0, pipeIdx).toCharArray(cmdBuf, sizeof(cmdBuf));
input.substring(pipeIdx + 1).toCharArray(paramsBuf, sizeof(paramsBuf));
} else {
input.toCharArray(cmdBuf, sizeof(cmdBuf));
}
// No request ID in Pipe format
}
String result = routeCommand(cmdBuf, paramsBuf);
// Inject request ID into response JSON
if (reqId.length() > 0 && result.length() > 2 && result.endsWith("}")) {
result = result.substring(0, result.length() - 1) + ",\"id\":" + reqId + "}";
}
return result;
}
[!IMPORTANT] The ID must be injected at the end, after the command handler has already built the response. Do not modify the individual command handlers — inject the ID centrally in your command dispatch function. This keeps every handler simple and ID-agnostic.
Argument Parsing
Positional Arguments (JSON args array)
When the JSON protocol is used with an args array, the args are joined with | before being passed to the command handler (for handler reuse with the Pipe protocol):
JsonArray args = doc["args"];
String joined = "";
for (size_t i = 0; i < args.size(); i++) {
if (i > 0) joined += "|";
joined += args[i].as<String>();
}
strncpy(paramsBuf, joined.c_str(), sizeof(paramsBuf) - 1);
This means {"cmd":"DIGITAL_WRITE","args":["D5","1"]} is internally equivalent to DIGITAL_WRITE|D5|1 before reaching the handler.
Pin String Parsing
Commands that accept a pin number accept multiple formats. Your parsePin() function must handle all of them:
| Input format | Example | Meaning |
|---|---|---|
| Pure number | "5" | GPIO pin 5 |
| D-prefix | "D5" | Digital pin 5 |
| A-prefix | "A0" | Analog pin (offset applied per board) |
| Underscore notation | "D5_5", "RELAY_1_21" | Last number after _ is the GPIO number |
int parsePin(String pinStr) {
pinStr.trim();
// Underscore notation: last segment after '_' is the GPIO number
int lastUnderscore = pinStr.lastIndexOf('_');
if (lastUnderscore > 0 && lastUnderscore < (int)pinStr.length() - 1) {
String numPart = pinStr.substring(lastUnderscore + 1);
bool allDigits = true;
for (unsigned int i = 0; i < numPart.length(); i++) {
if (!isDigit(numPart[i])) { allDigits = false; break; }
}
if (allDigits && numPart.length() > 0) return numPart.toInt();
}
if (pinStr.startsWith("D")) return pinStr.substring(1).toInt();
if (pinStr.startsWith("A")) return pinStr.substring(1).toInt() + 14; // A0 = pin 14 on UNO-style boards
// Pure number
bool allDigits = true;
for (unsigned int i = 0; i < pinStr.length(); i++) {
if (!isDigit(pinStr[i])) { allDigits = false; break; }
}
if (allDigits && pinStr.length() > 0) return pinStr.toInt();
return -1; // Invalid pin
}
[!WARNING] The
A0offset is board-specific. On Arduino UNO-style boards,A0maps to internal pin14. On ESP32, analog pins are referenced by their GPIO number directly (e.g.,34,35) — there is noA0-styleoffset. On ESP8266, there is only one analog pin and it is referenced asA0= GPIO17 (internally). If you copy thisparsePin()function to ESP32 firmware, theA0offset line must be adjusted or removed.
Buffer Sizes
Command strings can be long for complex bus commands. Your firmware must allocate sufficient buffer for both the input and the parameter extraction.
| Board | Recommended CMD buffer | Recommended Params buffer | Notes |
|---|---|---|---|
| Arduino UNO R4 WiFi | 512 bytes | 512 bytes | Tested — sufficient for all commands |
| ESP32 WROOM-32 | 512–1024 bytes | 512–1024 bytes | More RAM available — increase if needed |
| ESP8266 NodeMCU | 512 bytes | 512 bytes | Monitor heap usage — 80 KB RAM available |
| Arduino Nano | 128 bytes | 128 bytes | 🔴 Critical — larger buffers will overflow the 2 KB RAM |
[!CAUTION] On Arduino Nano, every byte of buffer counts. A 512-byte command buffer alone consumes 25% of the total RAM. Keep all buffers as small as you can justify. If you limit the Nano to only simple commands (ANALOG, DIGITAL_READ, DHT_READ), you never need to handle multi-hundred-byte JSON payloads.
ArduinoJson — Version and Usage
All firmware uses the ArduinoJson library for JSON parsing.
| Board | Recommended JsonDocument | Max size |
|---|---|---|
| Arduino UNO R4 WiFi | StaticJsonDocument<1024> for dispatch, DynamicJsonDocument<512> for bus handlers | |
| ESP32 WROOM-32 | DynamicJsonDocument<1024> | Ample RAM — can go higher |
| ESP8266 NodeMCU | StaticJsonDocument<512> | Monitor heap |
| Arduino Nano | StaticJsonDocument<128> only | ❌ Never use DynamicJsonDocument |
[!WARNING] Never use
DynamicJsonDocumenton Arduino Nano. Dynamic allocation on a 2 KB heap will cause heap/stack collisions and unpredictable crashes. Use onlyStaticJsonDocument<N>where N is as small as possible.
Install via Arduino Library Manager: search for “ArduinoJson” by Benoit Blanchon. Use version 6.x (version 7 has API changes).