#include <ArduinoJson.h>
#include <esp_sleep.h>
#include <HTTPClient.h>
#include <LiquidCrystal_I2C.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Wire.h>
#include "config.h"
#include "music.h"
#include "pins.h"
#include "svenska.h"
#include "trafiklab.model.h"
LiquidCrystal_I2C LCD = LiquidCrystal_I2C(0x27, 16, 2);
const char* SOLNA_CENTRUM_SITE_ID = "9305";
const char* VASTRA_SKOGEN_SITE_ID = "9306";
const char* URL = "https://transport.integration.sl.se/v1/sites/9305/departures?forecast=30&transport=METRO&direction=2";
// Sleep timeout - device goes to deep sleep after this duration to save battery
const unsigned long SLEEP_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
// Stored departures for animation
struct DisplayLine {
char direction[64]; // Increased for long station names
char display[16];
};
DisplayLine displayLines[2];
int displayLineCount = 0;
int scrollFrame = 0;
unsigned long lastScrollTime = 0;
const int SCROLL_INTERVAL_MS = 400;
// Background fetch task
volatile bool fetchInProgress = false;
TaskHandle_t fetchTaskHandle = NULL;
void spinner() {
static int8_t counter = 0;
const char* glyphs = "\xa1\xa5\xdb";
LCD.setCursor(15, 1);
LCD.print(glyphs[counter++]);
if (counter == strlen(glyphs)) {
counter = 0;
}
}
void updateOnlineState(const char* state) {
LCD.clear();
LCD.setCursor(0, 0);
LCD.print("Online");
LCD.setCursor(0, 1);
LCD.print(state);
}
void connectWiFi() {
WiFi.begin(WIFI_SSID, WIFI_PASS, 6); // TODO: why do we need 6?
Serial.print("Connecting to WiFi");
LCD.clear();
LCD.setCursor(0, 0);
LCD.print("Connecting...");
while (WiFi.status() != WL_CONNECTED) {
delay(SCROLL_INTERVAL_MS);
Serial.print(".");
spinner();
}
Serial.println("");
Serial.println("WiFi connected");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
updateOnlineState("Fetching data...");
}
bool isTargetLine(const char* designation, int direction_code) {
bool isLine10or11 = strcmp(designation, "10") == 0 || strcmp(designation, "11") == 0;
return isLine10or11;
// Direction is encoded as URL parameter, so no need to check it here
// bool isTowardsCentral = direction_code == 2;
// return isLine10or11 && isTowardsCentral;
}
SLDeparture parseDeparture(JsonObject d) {
SLDeparture dep;
dep.direction = d["direction"] | "";
dep.direction_code = d["direction_code"] | 0;
dep.destination = d["destination"] | "";
dep.state = d["state"] | "";
dep.scheduled = d["scheduled"] | "";
dep.expected = d["expected"] | "";
dep.display = d["display"] | "";
dep.line.id = d["line"]["id"] | 0;
dep.line.designation = d["line"]["designation"] | "";
dep.line.transport_mode = d["line"]["transport_mode"] | "";
dep.stop_area.id = d["stop_area"]["id"] | 0;
dep.stop_area.name = d["stop_area"]["name"] | "";
dep.journey.id = d["journey"]["id"] | 0;
dep.journey.state = d["journey"]["state"] | "";
return dep;
}
void formatLcdLine(const char* direction, const char* display, int frame, char* out, size_t outSize) {
int displayLen = strlen(display);
int dirLen = strlen(direction);
// If no direction, just right-align the display
if (dirLen == 0) {
char displayWithParens[20];
snprintf(displayWithParens, sizeof(displayWithParens), "%s", display);
snprintf(out, outSize, "%16s", displayWithParens);
return;
}
int maxDirLen = 16 - 1 - displayLen; // 1 for space
if (dirLen > maxDirLen) {
// Scrolling mode: create virtual string "direction direction" for seamless loop
char scrollBuf[140]; // 64 * 2 + separator + safety
snprintf(scrollBuf, sizeof(scrollBuf), "%s %s", direction, direction);
int scrollLen = dirLen + 1; // length of one cycle
int offset = frame % scrollLen;
snprintf(out, outSize, "%.*s %s", maxDirLen, scrollBuf + offset, display);
} else {
snprintf(out, outSize, "%-*s %s", maxDirLen, direction, display);
}
}
void fetchDeparturesInternal() {
Serial.println("Fetching departures...");
WiFiClientSecure client;
client.setInsecure();
HTTPClient https;
https.begin(client, URL);
int httpCode = https.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("HTTP error: %d\n", httpCode);
https.end();
return;
}
StaticJsonDocument<65536> doc;
DeserializationError err = deserializeJson(doc, https.getStream());
if (err) {
Serial.printf("JSON parse failed: %s\n", err.c_str());
https.end();
return;
}
JsonArray departures = doc["departures"];
if (departures.isNull()) {
Serial.println("No departures");
https.end();
return;
}
// Temporary storage to avoid partial updates
DisplayLine tempLines[2];
int tempCount = 0;
for (JsonObject d : departures) {
SLDeparture dep = parseDeparture(d);
Serial.printf(
"[%s %s] %s → %s (%s)\n",
dep.line.transport_mode,
dep.line.designation,
dep.stop_area.name,
dep.direction,
dep.display
);
// Store for animation (convert Swedish chars for LCD)
strncpy(tempLines[tempCount].direction, dep.direction, sizeof(tempLines[tempCount].direction) - 1);
tempLines[tempCount].direction[sizeof(tempLines[tempCount].direction) - 1] = '\0';
strncpy(tempLines[tempCount].display, dep.display, sizeof(tempLines[tempCount].display) - 1);
tempLines[tempCount].display[sizeof(tempLines[tempCount].display) - 1] = '\0';
convertSwedishChars(tempLines[tempCount].direction);
convertSwedishChars(tempLines[tempCount].display);
if (++tempCount > 1) break;
}
// If both lines have the same direction, clear the second one (show only time)
if (tempCount == 2 && strcmp(tempLines[0].direction, tempLines[1].direction) == 0) {
tempLines[1].direction[0] = '\0';
}
// Atomic update of display data
memcpy(displayLines, tempLines, sizeof(displayLines));
displayLineCount = tempCount;
scrollFrame = 0;
https.end();
}
// Background task for fetching (runs on core 0)
void fetchTask(void* parameter) {
fetchDeparturesInternal();
fetchInProgress = false;
vTaskDelete(NULL); // Self-delete when done
}
void startFetchInBackground() {
if (fetchInProgress) return; // Already fetching
fetchInProgress = true;
xTaskCreatePinnedToCore(
fetchTask, // Task function
"FetchTask", // Name
8192, // Stack size (bytes)
NULL, // Parameters
1, // Priority
&fetchTaskHandle, // Task handle
0 // Core 0 (animation runs on core 1)
);
}
// Blocking fetch for initial load
void fetchDepartures() {
fetchDeparturesInternal();
}
void updateDisplay() {
for (int i = 0; i < displayLineCount; i++) {
char line[17];
formatLcdLine(displayLines[i].direction, displayLines[i].display, scrollFrame, line, sizeof(line));
LCD.setCursor(0, i);
LCD.print(line);
}
}
void enterDeepSleep() {
Serial.println("Entering deep sleep to save battery...");
Serial.println("Press RESET button to wake up.");
// Kill background fetch task if running
if (fetchInProgress && fetchTaskHandle != NULL) {
vTaskDelete(fetchTaskHandle);
fetchTaskHandle = NULL;
fetchInProgress = false;
}
delay(100);
// Turn off LCD backlight
LCD.noBacklight();
LCD.clear();
delay(100);
// Disconnect WiFi to save power
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
delay(100);
// Small delay to let serial output flush
Serial.flush();
delay(500);
// Enter deep sleep
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
LCD.init();
LCD.backlight();
initSwedishChars(LCD);
connectWiFi();
fetchDepartures();
updateDisplay();
playJingle();
}
void loop() {
unsigned long now = millis();
// Check for sleep timeout
if (now >= SLEEP_TIMEOUT_MS) {
enterDeepSleep();
}
// Animate scrolling text
if (now - lastScrollTime >= SCROLL_INTERVAL_MS) {
lastScrollTime = now;
scrollFrame++;
updateDisplay();
}
// Refresh data every minute (non-blocking)
static unsigned long lastFetch = 0;
if (now - lastFetch >= 60000) {
lastFetch = now;
startFetchInBackground();
}
}