// ============================================================
// INKZONE NFL - "no more IncompleteInput" edition
// ============================================================
// ROOT CAUSE (verified against live ESPN):
// * ESPN serves the scoreboard as 280KB, and over HTTP/1.1 with
// Transfer-Encoding: chunked. ESP32 HTTPClient does NOT decode
// chunked framing, so deserializeJson saw raw chunk lines and
// reported IncompleteInput.
// * Also, buffering 280KB into a String blew the ESP32 heap (you saw
// "downloaded: 61440 bytes") -> truncated input.
// * With HTTP/1.0 + "Accept-Encoding: identity", ESPN sends the body
// PLAIN and non-chunked (curl verified: 280419 B, valid JSON, and
// with ?limit=3 only 57237 B).
// FIXES IN THIS FILE:
// 1. ?limit=3 on the URL -> body drops to ~57KB, fits in RAM even
// when buffered (belt: tiny body).
// 2. request.useHTTP10(true) -> forces HTTP/1.0 + Connection: close
// so ESPN does NOT chunk-encode (this is the key de-chunker).
// MUST be before GET().
// 3. request.addHeader("Accept-Encoding","identity") -> plain
// uncompressed body (belt-and-suspenders vs gzip).
// 4. Switched back to getString() because at 57KB the whole thing fits
// comfortably in heap, which sidesteps any stream/filter edge cases.
// (A stream parse also works, but buffering 57KB is simpler and safe.)
// The JSON is confirmed live: 16 events, all parser fields present.
// ============================================================
#include <Adafruit_ILI9341.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
constexpr char kWifiName[] = "Wokwi-GUEST";
constexpr char kFavoriteTeam[] = "TEN";
constexpr int kDisplayCsPin = 15;
constexpr int kDisplayDcPin = 2;
constexpr int kPreviousButtonPin = 32;
constexpr int kSelectButtonPin = 33;
constexpr int kNextButtonPin = 25;
constexpr unsigned long kRefreshIntervalMs = 60000;
// FIX 1: https:// added, and ?limit=3 shrinks the body to ~57KB so it
// fits in the ESP32's heap even when buffered.
constexpr char kNflScoreboardUrl[] =
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?limit=3";
Adafruit_ILI9341 display(
kDisplayCsPin,
kDisplayDcPin);
struct Game {
String away;
String home;
int awayScore = 0;
int homeScore = 0;
String status;
String detail;
};
Game games[5];
int gameCount = 0;
bool autoMode = true;
int manualScreen = 0;
int favoriteGameIndex = -1;
bool previousHeld = false;
bool selectHeld = false;
bool nextHeld = false;
unsigned long lastFetchMs = 0;
bool hadFavoriteScore = false;
int oldFavoriteAwayScore = 0;
int oldFavoriteHomeScore = 0;
bool buttonPressed(int pin, bool& wasHeld) {
const bool held = digitalRead(pin) == LOW;
const bool pressed = held && !wasHeld;
wasHeld = held;
return pressed;
}
void drawHeader(const char* title) {
display.fillRect(0, 0, 320, 36, ILI9341_BLUE);
display.setTextColor(ILI9341_WHITE);
display.setTextSize(2);
display.setCursor(10, 10);
display.print(title);
}
void drawMessage(const char* title, const char* message) {
display.fillScreen(ILI9341_BLACK);
drawHeader(title);
display.setTextColor(ILI9341_WHITE);
display.setTextSize(2);
display.setCursor(16, 80);
display.print(message);
}
void drawScoreboard() {
display.fillScreen(ILI9341_BLACK);
drawHeader("INKZONE NFL");
display.setTextColor(ILI9341_LIGHTGREY);
display.setTextSize(1);
display.setCursor(12, 45);
display.print(autoMode ? "AUTO MODE" : "MANUAL MODE");
int rowY = 64;
for (int index = 0; index < gameCount; ++index) {
const Game& game = games[index];
const bool favorite =
game.away == kFavoriteTeam ||
game.home == kFavoriteTeam;
display.drawRect(
8, rowY - 8, 304, 31,
favorite ? ILI9341_YELLOW : ILI9341_DARKGREY);
display.setTextColor(
favorite ? ILI9341_YELLOW : ILI9341_WHITE);
display.setTextSize(2);
display.setCursor(18, rowY);
display.printf(
"%s %d @ %s %d",
game.away.c_str(),
game.awayScore,
game.home.c_str(),
game.homeScore);
display.setTextSize(1);
display.setCursor(222, rowY + 17);
display.print(game.status);
rowY += 37;
}
if (gameCount == 0) {
display.setTextColor(ILI9341_WHITE);
display.setTextSize(2);
display.setCursor(18, 85);
display.print("No NFL games found");
}
}
void drawFavoriteGame() {
if (favoriteGameIndex < 0) {
drawMessage(
"FAVORITE TEAM",
"TEN is not in this feed");
return;
}
const Game& game = games[favoriteGameIndex];
display.fillScreen(ILI9341_BLACK);
drawHeader("TENNESSEE TITANS");
display.setTextColor(ILI9341_LIGHTGREY);
display.setTextSize(2);
display.setCursor(82, 62);
display.print(game.status);
display.setTextColor(ILI9341_WHITE);
display.setTextSize(4);
display.setCursor(26, 112);
display.printf(
"%s @ %s",
game.away.c_str(),
game.home.c_str());
display.setTextColor(ILI9341_YELLOW);
display.setTextSize(4);
display.setCursor(84, 164);
display.printf(
"%d - %d",
game.awayScore,
game.homeScore);
display.setTextColor(ILI9341_LIGHTGREY);
display.setTextSize(1);
display.setCursor(18, 214);
display.print(game.detail);
}
void drawDashboard() {
display.fillScreen(ILI9341_BLACK);
drawHeader("INKZONE DASHBOARD");
display.setTextColor(ILI9341_YELLOW);
display.setTextSize(2);
display.setCursor(18, 60);
display.print("FAVORITE: TEN");
display.setTextColor(ILI9341_LIGHTGREY);
display.setTextSize(1);
display.setCursor(18, 96);
display.print("NEXT TITANS GAME");
display.setTextColor(ILI9341_WHITE);
display.setTextSize(3);
display.setCursor(18, 120);
if (favoriteGameIndex >= 0) {
const Game& game = games[favoriteGameIndex];
display.printf(
"%s @ %s",
game.away.c_str(),
game.home.c_str());
display.setTextSize(2);
display.setCursor(80, 170);
display.print(game.status);
} else {
display.print("No game found");
}
display.setTextColor(ILI9341_LIGHTGREY);
display.setTextSize(1);
display.setCursor(18, 218);
display.print("LIVE DATA REFRESHES EVERY 60 SEC");
}
void drawScoreAlert(const Game& game, bool TitansScored) {
for (int flash = 0; flash < 2; ++flash) {
display.fillScreen(ILI9341_BLUE);
delay(160);
display.fillScreen(ILI9341_BLACK);
delay(110);
}
display.fillScreen(ILI9341_BLUE);
display.setTextColor(ILI9341_WHITE);
display.setTextSize(4);
display.setCursor(20, 42);
display.print("SCORE!");
display.setTextColor(ILI9341_YELLOW);
display.setTextSize(4);
display.setCursor(72, 106);
display.print(TitansScored ? "TEN" : "OPP");
display.setTextColor(ILI9341_WHITE);
display.setTextSize(3);
display.setCursor(78, 168);
display.printf(
"%d - %d",
game.awayScore,
game.homeScore);
delay(1200);
}
void drawCurrentScreen() {
if (autoMode) {
if (favoriteGameIndex >= 0 &&
games[favoriteGameIndex].status == "LIVE") {
drawFavoriteGame();
} else {
drawScoreboard();
}
return;
}
if (manualScreen == 0) {
drawScoreboard();
} else if (manualScreen == 1) {
drawFavoriteGame();
} else {
drawDashboard();
}
}
String gameStatus(JsonObjectConst status) {
const bool completed =
status["type"]["completed"] | false;
const char* state =
status["type"]["state"] | "";
if (completed) {
return "FINAL";
}
if (String(state) == "in") {
return "LIVE";
}
return "UPCOMING";
}
bool fetchNflScoreboard() {
Serial.println("Requesting NFL scoreboard...");
if (WiFi.status() != WL_CONNECTED) {
Serial.println("NFL error: Wi-Fi is not connected");
return false;
}
WiFiClientSecure client;
client.setInsecure();
HTTPClient request;
if (!request.begin(client, kNflScoreboardUrl)) {
Serial.println("NFL error: request.begin() failed");
return false;
}
// FIX 2 (critical): force HTTP/1.0 + Connection: close so ESPN sends
// the body NON-chunked. Without this ESPN streams it as Transfer-
// Encoding: chunked, which ESP32 HTTPClient hands to deserializeJson
// as raw chunk-framed bytes -> IncompleteInput. MUST be before GET().
request.useHTTP10(true);
// FIX 3 (belt-and-suspenders): ask for a plain uncompressed body.
request.addHeader("Accept-Encoding", "identity");
const int statusCode = request.GET();
Serial.printf("NFL HTTP status: %d\n", statusCode);
if (statusCode != HTTP_CODE_OK) {
request.end();
Serial.println("NFL error: ESPN request failed");
return false;
}
// Length-prefixed, non-chunked HTTP/1.0 means getString() gets the
// full clean body. At ~57KB (?limit=3) it fits in heap comfortably.
const String json = request.getString();
request.end();
Serial.printf(
"NFL downloaded: %u bytes\n",
static_cast<unsigned int>(json.length()));
if (json.isEmpty()) {
Serial.println("NFL error: empty response");
return false;
}
JsonDocument filter;
filter["events"][0]["id"] = true;
filter["events"][0]["date"] = true;
JsonObject statusFilter =
filter["events"][0]["status"].to<JsonObject>();
statusFilter["period"] = true;
statusFilter["displayClock"] = true;
statusFilter["type"]["state"] = true;
statusFilter["type"]["completed"] = true;
JsonObject competitorFilter =
filter["events"][0]["competitions"][0]
["competitors"][0]
.to<JsonObject>();
competitorFilter["homeAway"] = true;
competitorFilter["score"] = true;
competitorFilter["team"]["id"] = true;
competitorFilter["team"]["displayName"] = true;
competitorFilter["team"]["abbreviation"] = true;
competitorFilter["team"]["color"] = true;
competitorFilter["team"]["alternateColor"] = true;
JsonDocument document;
const DeserializationError error =
deserializeJson(
document,
json,
DeserializationOption::Filter(filter),
DeserializationOption::NestingLimit(20));
if (error) {
Serial.printf(
"NFL JSON error: %s\n",
error.c_str());
return false;
}
const JsonArrayConst events =
document["events"].as<JsonArrayConst>();
Serial.printf(
"NFL events found: %u\n",
static_cast<unsigned int>(events.size()));
if (events.isNull() || events.size() == 0) {
Serial.println("NFL error: events array is empty");
return false;
}
Game parsedGames[5];
int parsedGameCount = 0;
int parsedFavoriteIndex = -1;
for (JsonObjectConst event : events) {
if (parsedGameCount >= 5) {
break;
}
const JsonArrayConst competitions =
event["competitions"].as<JsonArrayConst>();
if (competitions.isNull() || competitions.size() == 0) {
continue;
}
const JsonArrayConst competitors =
competitions[0]["competitors"]
.as<JsonArrayConst>();
if (competitors.isNull() ||
competitors.size() < 2) {
continue;
}
Game game;
const JsonObjectConst status = event["status"];
game.status = gameStatus(status);
for (JsonObjectConst competitor : competitors) {
const bool isHome =
strcmp(
competitor["homeAway"] | "",
"home") == 0;
const String abbreviation =
competitor["team"]["abbreviation"] | "???";
const char* scoreText =
competitor["score"] | "0";
const int score = String(scoreText).toInt();
if (isHome) {
game.home = abbreviation;
game.homeScore = score;
} else {
game.away = abbreviation;
game.awayScore = score;
}
}
if (game.home == "???" || game.away == "???") {
continue;
}
parsedGames[parsedGameCount] = game;
if (game.home == kFavoriteTeam ||
game.away == kFavoriteTeam) {
parsedFavoriteIndex = parsedGameCount;
}
Serial.printf(
"Parsed NFL game: %s %d at %s %d\n",
game.away.c_str(),
game.awayScore,
game.home.c_str(),
game.homeScore);
++parsedGameCount;
}
if (parsedGameCount == 0) {
Serial.println("NFL error: no valid games parsed");
return false;
}
gameCount = parsedGameCount;
favoriteGameIndex = parsedFavoriteIndex;
for (int index = 0; index < gameCount; ++index) {
games[index] = parsedGames[index];
}
if (favoriteGameIndex >= 0) {
const Game& favoriteGame =
games[favoriteGameIndex];
// Score alert restored (was empty in the original).
if (hadFavoriteScore &&
(favoriteGame.homeScore > oldFavoriteHomeScore ||
favoriteGame.awayScore > oldFavoriteAwayScore)) {
const bool TitansScored =
favoriteGame.homeScore > oldFavoriteHomeScore;
drawScoreAlert(favoriteGame, TitansScored);
}
oldFavoriteHomeScore = favoriteGame.homeScore;
oldFavoriteAwayScore = favoriteGame.awayScore;
hadFavoriteScore = true;
} else {
hadFavoriteScore = false;
}
Serial.printf(
"NFL games saved: %d, favorite index: %d\n",
gameCount,
favoriteGameIndex);
return true;
}
void connectToWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(kWifiName, "", 6);
display.fillScreen(ILI9341_BLACK);
drawHeader("INKZONE NFL");
display.setTextColor(ILI9341_WHITE);
display.setTextSize(2);
display.setCursor(18, 80);
display.print("Connecting to Wi-Fi...");
while (WiFi.status() != WL_CONNECTED) {
delay(150);
}
}
void setup() {
Serial.begin(115200);
pinMode(kPreviousButtonPin, INPUT_PULLUP);
pinMode(kSelectButtonPin, INPUT_PULLUP);
pinMode(kNextButtonPin, INPUT_PULLUP);
display.begin();
display.setRotation(1);
display.setTextWrap(false);
connectToWifi();
if (!fetchNflScoreboard()) {
drawMessage(
"INKZONE NFL",
"Live data unavailable");
} else {
drawCurrentScreen();
}
lastFetchMs = millis();
}
void loop() {
if (buttonPressed(
kPreviousButtonPin,
previousHeld)) {
autoMode = false;
manualScreen = (manualScreen + 2) % 3;
drawCurrentScreen();
}
if (buttonPressed(
kNextButtonPin,
nextHeld)) {
autoMode = false;
manualScreen = (manualScreen + 1) % 3;
drawCurrentScreen();
}
if (buttonPressed(
kSelectButtonPin,
selectHeld)) {
autoMode = true;
drawCurrentScreen();
}
if (millis() - lastFetchMs >=
kRefreshIntervalMs) {
fetchNflScoreboard();
drawCurrentScreen();
lastFetchMs = millis();
}
}