Files
Crosspoint/lib/JsonParser/StreamingJsonParser.cpp
Zach Nelson aa7a31b3db fix: Read GH release JSON as stream in OTA updater (#1810)
## Summary

The GitHub recent release API returns lots of data, including the full
release notes. For 1.2.0, that data is 30,530 bytes. The existing
OtaUpdater HTTP handler and single-buffer JSON parsing can't reliably
handle that much data in the constrained ESP32 environment.

This change does a few things:
1. Adds a very simple lib/JsonParser/StreamingJsonParser.cpp with
SAX-style callbacks to read JSON data incrementally.
2. Adds a very simple lib/JsonParser/ReleaseJsonParser.cpp building on
StreamingJsonParser, which specifically parses the GitHub release JSON
for the release version, URL, and size.
3. Updates OtaUpdater.cpp to use an instance of ReleaseJsonParser to
incrementally parse the large response it may receive from GitHub.

Building from this commit while overriding my local version to 1.1.9, I
was able to run the OTA update process ~5 times in a row successfully.

Fixes #1561 (second part, after #1805).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_
2026-05-03 20:48:06 -05:00

250 lines
5.6 KiB
C++

#include "StreamingJsonParser.h"
#include <cstring>
StreamingJsonParser::StreamingJsonParser(const JsonCallbacks& callbacks) : cb(callbacks) { reset(); }
void StreamingJsonParser::reset() {
tokenLen = 0;
state = State::SCANNING;
expectingValue = false;
escaped = false;
tokenOverflow = false;
error = false;
nestingDepth = 0;
literalLen = 0;
literalPos = 0;
}
void StreamingJsonParser::feed(const char* data, size_t len) {
for (size_t i = 0; i < len && !error; ++i) {
char c = data[i];
switch (state) {
case State::SCANNING:
handleScanning(c);
break;
case State::IN_STRING_KEY:
case State::IN_STRING_VALUE:
handleStringChar(c);
break;
case State::IN_NUMBER:
handleNumber(c);
break;
case State::IN_LITERAL:
handleLiteral(c);
break;
case State::SKIP_STRING:
handleSkipString(c);
break;
}
}
}
void StreamingJsonParser::handleScanning(char c) {
switch (c) {
case '"':
tokenLen = 0;
tokenOverflow = false;
if (expectingValue || inArray()) {
state = State::IN_STRING_VALUE;
} else {
state = State::IN_STRING_KEY;
}
break;
case '{':
if (nestingDepth < MAX_NESTING) {
nestingStack[nestingDepth++] = Container::OBJECT;
} else {
error = true;
return;
}
if (cb.onObjectStart) cb.onObjectStart(cb.ctx);
expectingValue = false;
break;
case '}':
if (cb.onObjectEnd) cb.onObjectEnd(cb.ctx);
if (nestingDepth > 0) --nestingDepth;
expectingValue = false;
break;
case '[':
if (nestingDepth < MAX_NESTING) {
nestingStack[nestingDepth++] = Container::ARRAY;
} else {
error = true;
return;
}
if (cb.onArrayStart) cb.onArrayStart(cb.ctx);
expectingValue = false;
break;
case ']':
if (cb.onArrayEnd) cb.onArrayEnd(cb.ctx);
if (nestingDepth > 0) --nestingDepth;
expectingValue = false;
break;
case ':':
expectingValue = true;
break;
case ',':
expectingValue = false;
break;
case 't':
if (expectingValue || inArray()) {
memcpy(literalExpected, "true", 4);
literalLen = 4;
literalPos = 1;
state = State::IN_LITERAL;
}
break;
case 'f':
if (expectingValue || inArray()) {
memcpy(literalExpected, "false", 5);
literalLen = 5;
literalPos = 1;
state = State::IN_LITERAL;
}
break;
case 'n':
if (expectingValue || inArray()) {
memcpy(literalExpected, "null", 4);
literalLen = 4;
literalPos = 1;
state = State::IN_LITERAL;
}
break;
default:
if ((expectingValue || inArray()) && (c == '-' || (c >= '0' && c <= '9'))) {
tokenLen = 0;
tokenOverflow = false;
appendToken(c);
state = State::IN_NUMBER;
}
break;
}
}
void StreamingJsonParser::handleStringChar(char c) {
if (escaped) {
escaped = false;
switch (c) {
case '"':
case '\\':
case '/':
appendToken(c);
break;
case 'b':
appendToken('\b');
break;
case 'f':
appendToken('\f');
break;
case 'n':
appendToken('\n');
break;
case 'r':
appendToken('\r');
break;
case 't':
appendToken('\t');
break;
case 'u':
// Pass \uXXXX through as literal characters -- we don't decode
// Unicode escapes since our use case only needs ASCII field matching.
appendToken('\\');
appendToken('u');
break;
default:
appendToken('\\');
appendToken(c);
break;
}
return;
}
if (c == '\\') {
escaped = true;
return;
}
if (c == '"') {
emitToken();
return;
}
appendToken(c);
}
void StreamingJsonParser::handleNumber(char c) {
if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E') {
appendToken(c);
return;
}
if (!tokenOverflow && cb.onNumber) {
tokenBuf[tokenLen] = '\0';
cb.onNumber(cb.ctx, tokenBuf, tokenLen);
}
state = State::SCANNING;
expectingValue = false;
handleScanning(c);
}
void StreamingJsonParser::handleLiteral(char c) {
if (c == literalExpected[literalPos]) {
++literalPos;
if (literalPos == literalLen) {
if (literalExpected[0] == 't') {
if (cb.onBool) cb.onBool(cb.ctx, true);
} else if (literalExpected[0] == 'f') {
if (cb.onBool) cb.onBool(cb.ctx, false);
} else {
if (cb.onNull) cb.onNull(cb.ctx);
}
state = State::SCANNING;
expectingValue = false;
}
} else {
error = true;
}
}
void StreamingJsonParser::handleSkipString(char c) {
if (escaped) {
escaped = false;
return;
}
if (c == '\\') {
escaped = true;
return;
}
if (c == '"') {
state = State::SCANNING;
expectingValue = false;
}
}
void StreamingJsonParser::appendToken(char c) {
if (tokenLen < TOKEN_BUF_SIZE - 1) {
tokenBuf[tokenLen++] = c;
} else {
tokenOverflow = true;
}
}
void StreamingJsonParser::emitToken() {
if (state == State::IN_STRING_KEY) {
if (!tokenOverflow && cb.onKey) {
tokenBuf[tokenLen] = '\0';
cb.onKey(cb.ctx, tokenBuf, tokenLen);
}
state = State::SCANNING;
} else {
if (!tokenOverflow && cb.onString) {
tokenBuf[tokenLen] = '\0';
cb.onString(cb.ctx, tokenBuf, tokenLen);
}
state = State::SCANNING;
expectingValue = false;
}
}