Connecting to an API for evil

I am working on a project with my Arduino Nano 33 IoT. My goal is to have the Arduino constantly (realistically every few seconds) retrieve the Chicago Blackhawks score, sounding a loud buzzer and flashing an alarm light, which will scare me every single time. I found the NHL's base API for scores, which I am assuming will be the way to go.

My first step here is having the Arduino make the get request. I have functional code to connect it to WiFi, and am having no problems with that. I have function python code to make the get request, and I am struggling to adapt it for the arduino language/libraries.

Python code:

import requests

BASE_SITE = "https://api-web.nhle.com/v1"

def get_data(team):
   req = requests.get(BASE_SITE + "/scoreboard/{team:}/now")
   data = reg.json()

   return data

All I need to do with this to make a request then is get_data("CHI").

I am not interested in the formatting of the json yet, I can clean it later.

Here is my arduino code (not including the WiFiNINA connection):

#include <SPI.h>
#include <WiFiNINA.h>
#include <ArduinoHttpClient.h>

void getNHLData() {
    // Initialize the HTTP client
    WiFiClient wifiClient;
    HttpClient client(wifiClient, "api-web.nhle.com/v1/scoreboard/CHI/now", 80);

    // Make the GET request
    client.get("/v1/scoreboard/CHI/now");

    // Check for response status
    int statusCode = client.responseStatusCode();
    Serial.print("HTTP Status Code: ");
    Serial.println(statusCode);

    if (statusCode == 200) { // If the request was successful
        String response = client.responseBody();
        Serial.println("Response received:");
        Serial.println(response);
    } else {
        Serial.println("Failed to get data");

        String locationHeader = client.responseheader("Location");
        Serial.print("Redirected to: ");
        Serial.println(locationHeader);

    }
}

I have been able to ping other websites with no problem, but for some reason it does not like the API. I will admit that I am not well versed in APIs or computer science in general. What directions should I take here? Are there different libraries I should be using? Is the API idea a complete swing and miss?

The API endpoint you're currently using returns a lot of data, if all you want is the current score. It can be unwieldy to clean it on the Arduino; although certainly doable as long as it fits in memory. You might want to invest time finding a better endpoint or if there are any parameters that can reduce the data down to what you want.

Your code does not compile. HttpClient does not have a responseheader function. (If it did, it would probably be capitalized responseHeader.) Their example is not great. If you read the function descriptions, note that the headers must be retrieved between the status code and body, which is the order they appear in an HTTP response. As a general hint, when you don't get the expected 200 response, the body will often have useful info detailing the problem, so you should always print it. (Spoiler: not really in this case)

The HttpClient constructor takes a server name or IPAddress, not a full URL

  WiFiClient wifiClient;
  HttpClient client(wifiClient, "api-web.nhle.com", 80);

  client.get("/v1/scoreboard/CHI/now");

  int statusCode = client.responseStatusCode();
  Serial.print("HTTP Status Code: ");
  Serial.println(statusCode);
  if (statusCode != 200) {
    while (client.headerAvailable()) {
      String name = client.readHeaderName();
      if (name == "Location") {
        Serial.print(name);
        Serial.print(": ");
        Serial.println(client.readHeaderValue());
        break;
      }
    }
  }
  String response = client.responseBody();
  Serial.println("Response received:");
  Serial.println(response);

I get

HTTP Status Code: 301
Location: https://api-web.nhle.com/v1/scoreboard/CHI/now
Response received:
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>

As in your Python code

note the URL is https, not plain http. That means you'll need to use WiFiSSLClient instead of plain WiFiClient. Also that happens on port 443 instead of 80.

    WiFiSSLClient wifiClient;
    HttpClient client(wifiClient, "api-web.nhle.com", 443);

WiFiSSLClient relies on a set of common root certificates baked into the NINA firmware. If you're lucky, NHL uses one of those for their server.

I don't know how to connect to the "evil". I do not even know where the "gate to hell" is

1 Like

You can almost always add a .com to anything you are looking for.

1 Like

Thanks for your response! I found a post that was extremely similar to mine (I swear, I checked before posting, somehow I missed it!) and I adapted the example code they used to fit my application. Conveniently, it featured all of the things you suggested.

You are absolutely right about the colossal data size. My arduino is now connecting to the api with no problem, but stopping hard at the line where I ask for the response.

/*
  Simple GET client for ArduinoHttpClient library
  Connects to server once every five seconds, sends a GET request

  created 14 Feb 2016
  modified 22 Jan 2019
  by Tom Igoe
  
  this example is in the public domain
 */
#include <ArduinoHttpClient.h>
#include <WiFiNINA.h>

#include "arduino_secrets.h"

///////please enter your sensitive data in the Secret tab/arduino_secrets.h
/////// Wifi Settings ///////
char ssid[] = SECRET_SSID;
char pass[] = SECRET_PASS;

char serverAddress[] = "api-web.nhle.com";  // server address
int port = 443;

WiFiSSLClient wifi;
HttpClient client = HttpClient(wifi, serverAddress, port);
int status = WL_IDLE_STATUS;

void setup() {
  Serial.begin(9600);
  while ( status != WL_CONNECTED) {
    Serial.print("Attempting to connect to Network named: ");
    Serial.println(ssid);                   // print the network name (SSID);

    // Connect to WPA/WPA2 network:
    status = WiFi.begin(ssid, pass);
  }

  // print the SSID of the network you're attached to:
  Serial.print("SSID: ");
  Serial.println(WiFi.SSID());

  // print your WiFi shield's IP address:
  IPAddress ip = WiFi.localIP();
  Serial.print("IP Address: ");
  Serial.println(ip);
}

void loop() {
  Serial.println("making GET request");
  client.get("/v1/scoreboard/CHI/now");

  // read the status code and body of the response
  int statusCode = client.responseStatusCode();
  Serial.print("Status code: ");
  Serial.println(statusCode);
  String response = client.responseBody();
  Serial.print("Response: ");
  Serial.println(response);
  Serial.println("Wait five seconds");
  delay(5000);
}

In the serial monitor, it is printing:
20:58:57.393 -> Attempting to connect to Network named: ******

20:58:57.680 -> SSID: ******

20:58:57.680 -> IP Address: *********

20:58:57.680 -> making GET request

20:59:03.331 -> Status code: 200

which is were it ends. I have given it time, seeing if maybe it just needs a minute, but nothing.

I cannot find any documentation for a way to only retrieve a little bit of the body. Is this an easy task? Is there better documentation than the GitHub page?

I dont know if the library supports it, but range-requests are a part of the http/https protocol.

responseBody returns a String; if there's a Content-Length in the headers, it will reserve that amount of space. That way, the String won't have to repeatedly grow, which can cause heap fragmentation. Unfortunately, perusing the headers

$ curl -i --http1.1 https://api-web.nhle.com/v1/scoreboard/CHI/now
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked

it's chunked instead. The total size right now is about 19KB. I used ArduinoHttpClient on ESP32 (instead of the built-in one), where there is enough memory, and had it report when the buffer was relocated after adding another char: length and hex address

0
3FFB222C
15
3FFD6904
16
3FFD759C
224
3FFD5204
1040
3FFD6068
1520
3FFD797C
1920
3FFDA6E0
1968
3FFDB7B4
7232
3FFDDD18
8480
3FFD94B8
10032
3FFE05EC
14624
3FFD797C
15344
3FFE46C4
body length: 19004

The buffer moves around a bit. So you need even more memory than the payload itself. The Nano 33 IoT has 32KB of RAM?

I also fiddled with the payload, to get an idea of what you'd need to get the current score. This is JavaScript, which you can run in the browser's developer Console (after having the browser make the API call so you're on the same domain). Do it when there is a live game.

// get JSON payload
j = await fetch('https://api-web.nhle.com/v1/scoreboard/CHI/now').then(r => r.json());
// find LIVE game
g = j.gamesByDate.flatMap(g => g.games).find(g => g.gameState == 'LIVE');
// extract scores
s = g && [g.awayTeam, g.homeTeam].map(({abbrev, score}) => ({team: abbrev, score}))
// team score
t = s?.find(t => t.team == 'CHI').score

Even if you retrieve parts of the body, you'd need to find the "LIVE" game -- if I understand what you're trying to do with it. Then grab the score from either the "awayTeam" or "homeTeam" in the same nested JSON object, whichever is "CHI". It would be at a different location every (game) day.

You could actually do this with a state machine that understands at least a subset of JSON syntax, reading one byte at a time

class ScoreMachine {
  String team;
  unsigned long pos, bad;
  String level;
  unsigned gameLevel;
  enum Place {
    zero,  // default, could start anything
    name,  // after { or following ,
    str,   // inside double-quotes
    num,   // in a number
  } place = zero, last = zero;
  bool escape = false;
  String word;
  String strVal;
  double numVal;
  void shift(Place p) {
    last = place;
    place = p;
  }
  void stat(bool k) {
    if (!bad && !k) {
      bad = pos;
    }
  }
  char currentLevel() {
    auto len = level.length();
    if (len) {
      return level[--len];
    }
    return '\0';
  }
  void pop(char c) {
    auto len = level.length();
    if (len) {
      c = c == '}' ? '{' : '[';
      if (c == level[--len]) {
        level.remove(len);
        return;
      }
    }
    stat(false);
  }
  String &quoted() {
    return last == name ? word : strVal;
  }
  char backslash(char c) {
    switch (c) {
      case '\\': return '\\';
      case 'n': return '\n';
      case '"': return '"';
    }
    return c;
  }
  String gameState;
  String abbrev;
  int score = -1;
public:
  int answer = -1;
  ScoreMachine(String teamAbbrev) : team(teamAbbrev) {}
  bool feed(char c) {
    ++pos;
    if (bad) {
      return true;
    }
    switch (place) {
      case str:
        if (escape) {
          stat(quoted().concat(backslash(c)));
          escape = false;
        } else if (c == '"') {
          place = zero;
          if (word == "abbrev") {
            abbrev = quoted();
          } else if (word == "gameState") {
            gameState = quoted();
            gameLevel = level.length();
          }
        } else if (c == '\\') {
          escape = true;
        } else {
          stat(quoted().concat(c));
        }
        return false;
      case num:
        if (c >= '0' && c <= '9') {
          numVal *= 10;
          numVal += c - '9';
          return false;
        } else if (c == '.') {
          //TODO
          return false;
        }
        shift(zero);
        if (word == "score") {
          score = numVal;
        }
        //fallthrough
    }
    switch (c) {
      case ' ': case '\t': case '\n': case '\r':
        return false;
      case ':':
        if (last != name) {
          stat(false);
        }
        shift(zero);
        return false;
      case ',':
        if (currentLevel() == '{') {
          shift(name);
        }
        return false;
      case '{': case '[':
        stat(level.concat(c));
        if (c == '{') {
          shift(name);
        }
        return false;
      case '}': case ']':
        pop(c);
        if (c == '}') {
          if (answer < 0 && gameState == "LIVE" && abbrev == team && score >= 0) {
            answer = score;
            return true;
          }
          if (level.length() == gameLevel) {
            score = -1;
          }
        }
        return false;
      case '"':
        shift(str);
        quoted().remove(0);
        return false;
      case '0'...'9':
        numVal = c - '0';
        shift(num);
        return false;
    }
    return false;
  }
  unsigned long badPos() {
    return bad;
  }
} m("CHI");  // your team abbreviation here

void loop() {
  Serial.println("making GET request");
  client.get("/v1/scoreboard/CHI/now");

  int statusCode = client.responseStatusCode();
  Serial.print("Status code: ");
  Serial.println(statusCode);
  client.skipResponseHeaders();
  if (client.isResponseChunked()) {
    Serial.println("chunked!");
  }
  if (statusCode == 200) {
    unsigned long len = 0;
    do {
      int c = client.read();
      if (c >= 0) {
        ++len;
        // if (len % 100 == 0) Serial.println(len);
        if (m.feed(static_cast<char>(c))) {
          break;
        }
      }
    } while (client.available());  // read loop could be more robust
    if (m.answer >= 0) {
      Serial.print("at: ");
      Serial.print(len);
      Serial.print(" score: ");
      Serial.println(m.answer);
    } else if (m.badPos()) {
      Serial.print("scan broken at pos: ");
      Serial.println(m.badPos());
    } else {
      Serial.print("not found after: ");
      Serial.println(len);
    }
  } else {
    Serial.println(client.responseBody());
  }
  client.stop();
  for (;;); // only try once
}

The extra code is a lot smaller than a typical payload. Worked at least once (with a saved payload)

making GET request
Status code: 200
chunked!
at: 11055 score: 3

End of the hallway, hook a left.