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.

FormatExampleUsed for
JSON (external interface){"cmd":"ANALOG","id":1,"args":["A0"]}All commands
Pipe (optimized core)ANALOG|A0Simple commands — used internally for speed

JSON Protocol

Structure

{
  "cmd": "COMMAND_NAME",
  "id": 42,
  "args": ["param1", "param2"],
  "namedParam1": "value",
  "namedParam2": 100
}
FieldTypeRequiredDescription
cmdstring✅ YesCommand name (uppercase, e.g. "ANALOG")
idinteger❌ NoRequest ID — echoed back in the response for correlation
argsarrayDepends on commandPositional arguments
Additional fieldsanyDepends on commandNamed 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 positional args. 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 id field)

[!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 id field.

[!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 formatExampleMeaning
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 A0 offset is board-specific. On Arduino UNO-style boards, A0 maps to internal pin 14. On ESP32, analog pins are referenced by their GPIO number directly (e.g., 34, 35) — there is no A0-style offset. On ESP8266, there is only one analog pin and it is referenced as A0 = GPIO17 (internally). If you copy this parsePin() function to ESP32 firmware, the A0 offset 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.

BoardRecommended CMD bufferRecommended Params bufferNotes
Arduino UNO R4 WiFi512 bytes512 bytesTested — sufficient for all commands
ESP32 WROOM-32512–1024 bytes512–1024 bytesMore RAM available — increase if needed
ESP8266 NodeMCU512 bytes512 bytesMonitor heap usage — 80 KB RAM available
Arduino Nano128 bytes128 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.

BoardRecommended JsonDocumentMax size
Arduino UNO R4 WiFiStaticJsonDocument<1024> for dispatch, DynamicJsonDocument<512> for bus handlers
ESP32 WROOM-32DynamicJsonDocument<1024>Ample RAM — can go higher
ESP8266 NodeMCUStaticJsonDocument<512>Monitor heap
Arduino NanoStaticJsonDocument<128> only❌ Never use DynamicJsonDocument

[!WARNING] Never use DynamicJsonDocument on Arduino Nano. Dynamic allocation on a 2 KB heap will cause heap/stack collisions and unpredictable crashes. Use only StaticJsonDocument<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).