docs/remote_callback_design.md
Remote doesn't handle strings, JSON parsing, or streams. Instead:
RpcRequestnamespace fl {
// Request source: Returns next validated request, or nullopt if none available
using RequestSource = fl::function<fl::optional<RpcRequest>()>;
// Response sink: Accepts response for output (user handles formatting/writing)
using ResponseSink = fl::function<void(const fl::json& response)>;
}
class Remote {
public:
// Callback registration
void setRequestSource(RequestSource source);
void setResponseSink(ResponseSink sink);
// Main update - pulls requests, processes, pushes responses
size_t update(u32 currentTimeMs);
// Optional: manual control
size_t pull(); // Pull from source, process, queue responses
size_t tick(u32 currentTimeMs); // Process scheduled (existing)
size_t push(); // Push queued responses to sink
private:
RequestSource mRequestSource;
ResponseSink mResponseSink;
fl::vector<fl::json> mOutgoingQueue; // Responses waiting to be sent
};
size_t Remote::update(u32 currentTimeMs) {
size_t processed = 0;
// 1. Pull requests from source
if (mRequestSource) {
while (auto request = mRequestSource()) {
auto response = processRpc(*request);
// Build response JSON
fl::json responseJson = fl::json::object();
if (response.ok()) {
responseJson.set("status", "ok");
if (response.value().has_value()) {
responseJson.set("result", response.value());
}
} else {
responseJson.set("status", "error");
responseJson.set("error", errorToString(response.error()));
}
// Queue for output
mOutgoingQueue.push_back(responseJson);
processed++;
}
}
// 2. Process scheduled calls
size_t executed = tick(currentTimeMs);
// 3. Push responses to sink
if (mResponseSink) {
// Push immediate responses
while (!mOutgoingQueue.empty()) {
mResponseSink(mOutgoingQueue[0]);
mOutgoingQueue.erase(mOutgoingQueue.begin());
}
// Push scheduled results
auto results = getResults();
for (const auto& r : results) {
mResponseSink(r.to_json());
}
}
return processed + executed;
}
class SerialRpcHandler {
fl::string inputBuffer;
public:
// Request source: Read from Serial, parse, validate
fl::optional<fl::Remote::RpcRequest> getNextRequest() {
// Read available data
while (Serial.available()) {
int c = Serial.read();
if (c < 0) break;
if (c == '\n' || c == '\r') {
if (!inputBuffer.empty()) {
// Parse JSON
fl::json doc = fl::json::parse(inputBuffer);
inputBuffer.clear();
// Validate and extract fields
if (!doc.has_value()) {
FL_WARN("Invalid JSON, skipping");
continue; // Skip malformed JSON
}
fl::string funcName = doc["function"] | fl::string("");
if (funcName.empty()) {
FL_WARN("Missing function field, skipping");
continue; // Skip invalid request
}
// Return validated request
return fl::Remote::RpcRequest{
funcName,
doc["args"],
static_cast<fl::u32>(doc["timestamp"] | 0)
};
}
} else {
inputBuffer += static_cast<char>(c);
// Safety: Prevent buffer overflow
if (inputBuffer.size() > 1024) {
FL_ERROR("Input buffer overflow, clearing");
inputBuffer.clear();
}
}
}
return fl::nullopt; // No complete request available
}
// Response sink: Format and write to Serial
void sendResponse(const fl::json& response) {
fl::Remote::printJson(response); // Uses existing helper
}
};
// Setup
SerialRpcHandler handler;
void setup() {
Serial.begin(115200);
FastLED.addLeds<WS2812, DATA_PIN>(leds, NUM_LEDS);
remote.bind("setLed", [](int i, int r, int g, int b) {
leds[i] = CRGB(r, g, b);
});
// Attach callbacks
remote.setRequestSource([&]() { return handler.getNextRequest(); });
remote.setResponseSink([&](const fl::json& r) { handler.sendResponse(r); });
}
void loop() {
remote.update(millis()); // One call does everything
FastLED.show();
delay(10);
}
Separation of Concerns
Error Isolation
Transport Agnostic
Filtering & Security
Testing
Backward Compatible
Simple Remote Implementation
User Complexity
Duplicated Logic
No Standard Pattern
// Option A: User does everything
remote.setRequestSource([&]() {
// 20 lines of parsing/validation
return request;
});
// Option B: Remote provides helpers
remote.setRequestSource([&]() {
return fl::Remote::parseRequestFromStream(&Serial); // Built-in helper
});
Recommendation: Provide helper functions for common cases:
fl::Remote::parseRequestFromStream(Stream* stream) - handles line buffering + JSON parsingfl::Remote::parseRequestFromString(const fl::string& line) - just JSON parsing// Current: JSON response
using ResponseSink = fl::function<void(const fl::json& response)>;
// Alternative: Typed response struct
struct Response {
enum class Status { Ok, Error, Warning };
Status status;
fl::json result; // Only present for Ok
fl::string errorMessage; // Only present for Error
Error errorCode; // Only present for Error
};
using ResponseSink = fl::function<void(const Response& response)>;
Recommendation: Use typed Response struct:
// Option A: One at a time
using RequestSource = fl::function<fl::optional<RpcRequest>()>;
// Option B: Batch
using RequestSource = fl::function<fl::vector<RpcRequest>()>;
Recommendation: Keep one-at-a-time for simplicity. User can implement batching externally if needed.
// Option A: Nullable (backward compat)
if (mRequestSource) {
auto req = mRequestSource();
}
// Option B: Always present (use no-op defaults)
mRequestSource = []() { return fl::nullopt; }; // Default
Recommendation: Nullable with runtime checks. Matches "optional feature" design.
// Option A: update() includes tick
size_t update(u32 currentTimeMs) {
pull();
tick(currentTimeMs);
push();
}
// Option B: user calls tick separately
void loop() {
remote.update(); // Just I/O
remote.tick(millis()); // Scheduling separate
}
Recommendation: Option A - update() includes tick() for simplicity. Advanced users can call pull/tick/push separately.
class Remote {
public:
// Response struct (typed, not raw JSON)
struct Response {
enum class Status { Ok, Error };
Status status;
fl::json result; // Present for Ok with return value
fl::string errorMessage; // Present for Error
Error errorCode; // Present for Error
fl::json to_json() const; // Convert to JSON if needed
};
// Callback types
using RequestSource = fl::function<fl::optional<RpcRequest>()>;
using ResponseSink = fl::function<void(const Response& response)>;
// Callback registration (optional - maintains backward compat)
void setRequestSource(RequestSource source);
void setResponseSink(ResponseSink sink);
void clearCallbacks();
// Main update (includes tick)
size_t update(u32 currentTimeMs);
// Manual control (for advanced users)
size_t pull(); // Pull requests, process, queue responses
size_t push(); // Push queued responses to sink
// tick() already exists
// Helper utilities for common cases
static fl::optional<RpcRequest> parseRequestFromLine(const fl::string& line);
static void writeResponseToStream(Stream* stream, const Response& response);
private:
RequestSource mRequestSource;
ResponseSink mResponseSink;
fl::vector<Response> mOutgoingQueue;
};
fl::string inputBuffer;
void setup() {
Serial.begin(115200);
// Use helper for standard Serial I/O
remote.setRequestSource([&]() {
// Line buffering (user still controls this)
while (Serial.available()) {
int c = Serial.read();
if (c == '\n' && !inputBuffer.empty()) {
auto req = fl::Remote::parseRequestFromLine(inputBuffer);
inputBuffer.clear();
return req; // May be nullopt if parse failed
}
inputBuffer += (char)c;
}
return fl::nullopt;
});
remote.setResponseSink([](const auto& r) {
fl::Remote::writeResponseToStream(&Serial, r);
});
}
void loop() {
remote.update(millis()); // One call
}
void setup() {
// Full control for custom transport
remote.setRequestSource([&]() {
// Read from network, apply rate limiting, authentication, etc.
return customNetworkParser.getNext();
});
remote.setResponseSink([&](const auto& response) {
// Custom formatting, encryption, batching, etc.
customNetworkWriter.send(response);
});
}
The callback-based design is superior because:
The key insight: Remote should coordinate RPC execution, not parse transport data.
Shall I implement this design?