diff --git a/README.md b/README.md index e9fc523..e0a9be7 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ - [ ] Make port configurable - [ ] Use unidecode to replace non-ascii stuff - [ ] Add query parameter for selecting EFAClient +- [ ] Return current time for displaying clock ### Client - [x] Write ESP8266 client - [x] Add code for handling button presses +- [ ] Add clock diff --git a/client/client.ino b/client/client.ino index 9bdf47e..98a0b47 100644 --- a/client/client.ino +++ b/client/client.ino @@ -21,10 +21,10 @@ #define FRAME_DELAY 80 #define COLOR_BG (rgb(0, 0, 0)) #define COLOR_DIVIDER (rgb(0xff, 0x60, 0x0d)) +#define COLOR_TOP (rgb(0xa0, 0xa0, 0xa0)) #define COLOR_TEXT (rgb(0xff, 0x60, 0x0d)) // Layout - #define WIDTH 160 #define HEIGHT 128 @@ -34,24 +34,48 @@ #define MARGIN_LEFT 5 #define MARGIN_RIGHT 5 +#define TOP_X MARGIN_LEFT +#define TOP_Y 4 +#define TOP_WIDTH (WIDTH - 2 * MARGIN_LEFT) +#define TOP_HEIGHT CY + #define MAIN_X MARGIN_LEFT -#define MAIN_Y 9 +#define MAIN_Y 19 #define MAIN_WIDTH (WIDTH - 2 * MARGIN_LEFT) #define MAIN_HEIGHT (8 * CY) #define CLOCK_X MARGIN_LEFT #define CLOCK_Y 113 +#define CLOCK_WIDTH (5 * CX) +#define CLOCK_HEIGHT CY #define STATUS_X 83 #define STATUS_Y CLOCK_Y #define STATUS_WIDTH (12 * CX) #define STATUS_HEIGHT CY -// Function definitions +struct Timetable { + const char *label; + const char *path; +}; +// Selectable timetables +Timetable timetables[] = { + {"Johanneskirche, Bstg. 1", "/departures?stop_id=de:08311:30104&platform=1"}, + {"Johanneskirche, Bstg. 2", "/departures?stop_id=de:08311:30104&platform=2"}, + {"Blumenthalstr., Bstg. A", "/departures?stop_id=de:08221:1225&platform=A"}, + {"Blumenthalstr., Bstg. B", "/departures?stop_id=de:08221:1225&platform=B"} +}; +size_t selectedTimetable = 0; + +// Function definitions void drawLayout(); +void clearTop(); +void clearStatus(); +void clearMain(); +void clearClock(); uint16_t rgb(uint16_t, uint16_t, uint16_t); -String fetchDepartures(); +String fetchDepartures(String); // Peripherals @@ -59,6 +83,32 @@ Adafruit_ST7735 display(TFT_CS, TFT_DC, TFT_RESET); // App state interface +// init +// │ +// │ +// │ +// │ ┌───┐ +// ┌──▼──────▼┐ │ +// │Connecting│ │!connected +// └──┬───▲──┬┘ │ +// │ │ └───┘ +// │ │ +// connected│ │!connected +// │ │ +// │ │ +// │ │ ┌───┐ +// ┌──▼───┴─┐ timeout (3s) ┌────────▼┐ │ +// │Fetching◄──────────────────┤Selecting│ │ +// └──┬───▲─┘ └────▲───┬┘ │button pressed +// │ │ │ └───┘ +// got response│ │timeout (25s) │ +// │ │ │ +// │ │ │ +// │ │ │button pressed +// ┌────▼───┴─────────┐ │ +// │Showing Departures│───────────────┘ +// └──────────────────┘ + class State { public: // When the state is entered @@ -93,6 +143,15 @@ public: void button() override; }; +class StateSelecting : public State { +private: + unsigned long entered; +public: + void enter() override; + void tick() override; + void button() override; +}; + // Empty default implementations void State::enter() {} @@ -117,6 +176,7 @@ void StateConnecting::enter() { display.fillRect(STATUS_X, STATUS_Y, STATUS_WIDTH, STATUS_HEIGHT, COLOR_BG); display.setCursor(STATUS_X, STATUS_Y); + display.setTextColor(COLOR_TEXT); display.printf("connecting"); } @@ -124,6 +184,7 @@ void StateConnecting::tick() { if (WiFi.status() != WL_CONNECTED) { display.fillRect(STATUS_X + 11 * CX, STATUS_Y, CX, CY, COLOR_BG); display.setCursor(STATUS_X + 11 * CX, STATUS_Y); + display.setTextColor(COLOR_TEXT); display.print("|/-\\"[currentTick % 4]); delay(125); return; @@ -143,12 +204,12 @@ void StateFetching::enter() { setState(); return; } - display.fillRect(STATUS_X, STATUS_Y, STATUS_WIDTH, STATUS_HEIGHT, COLOR_BG); + display.setTextColor(COLOR_TEXT); display.setCursor(STATUS_X, STATUS_Y); display.print(" fetching"); - String departuresRaw = fetchDepartures(); + String departuresRaw = fetchDepartures(timetables[selectedTimetable].path); setState(departuresRaw); }; @@ -161,18 +222,26 @@ void StateShowingDepartures::enter() { Serial.println("Entering StateShowingDepartures"); entered = millis(); - // clear status and main areas - display.fillRect(STATUS_X, STATUS_Y, STATUS_WIDTH, STATUS_HEIGHT, COLOR_BG); - display.fillRect(MAIN_X, MAIN_Y, MAIN_WIDTH, MAIN_HEIGHT, COLOR_BG); + clearStatus(); + clearMain(); // draw timetable + display.setTextColor(COLOR_TEXT); int line = 0; for (JsonVariant departure : departures["departures"].as()) { + if (line > 8) { + break; + } + + + char symbol[3] = {0}; + char direction[16] = {0}; + + utf8ToCP437German(departure["symbol"].as(), symbol, std::size(symbol)); + utf8ToCP437German(departure["direction"].as(), direction, std::size(direction)); + display.setCursor(MAIN_X, MAIN_Y + (CY + 3) * line); - display.printf("%2s %-15.15s ", - departure["symbol"].as(), - departure["direction"].as() - ); + display.printf("%2s %-15.15s ", symbol, direction); if (departure["leaving"].as().equals("sofort")) { int16_t x = display.getCursorX(); int16_t y = display.getCursorY(); @@ -193,9 +262,35 @@ void StateShowingDepartures::tick() { } void StateShowingDepartures::button() { - setState(); + setState(); } +void StateSelecting::enter() { + Serial.println("Entering StateSelecting"); + entered = millis(); + + clearMain(); + clearStatus(); +} + +void StateSelecting::tick() { + unsigned long elapsed = millis() - entered; + if (elapsed > 3000) { + setState(); + } +} + +void StateSelecting::button() { + entered = millis(); + selectedTimetable = (selectedTimetable + 1) % (sizeof timetables / sizeof timetables[0]); + clearTop(); + display.setCursor(TOP_X, TOP_Y); + display.setTextColor(COLOR_TOP); + display.print(timetables[selectedTimetable].label); +} + +// button ISR setup + int buttonPushed = false; void ICACHE_RAM_ATTR onButtonFalling() { @@ -208,13 +303,15 @@ void setup() { Serial.println("serial init"); display.initR(); display.setSPISpeed(40000000); + display.cp437(true); display.setRotation(3); Serial.println("display initialized"); Serial.printf("display dimensions: %" PRId16 "x%" PRId16 "\n", display.width(), display.height()); - display.setTextSize(1); - display.setTextColor(COLOR_TEXT); drawLayout(); + display.setCursor(TOP_X, TOP_Y); + display.setTextColor(COLOR_TOP); + display.print(timetables[selectedTimetable].label); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); @@ -224,14 +321,14 @@ void setup() { state = std::make_unique(); } -#define QUERY_STRING "stop_id=de:08221:1225&platform=A" - void loop() { if (stateChanged) { stateChanged = false; // Note: enter() may call setState(). In that case we want to end up right back here. state->enter(); } else if (buttonPushed) { + // "Handle" jitter + delay(100); buttonPushed = false; state->button(); } else { @@ -247,22 +344,36 @@ uint16_t rgb(uint16_t r, uint16_t g, uint16_t b) { return ((b >> 3) & 0b11111) << 11 | ((g >> 2) & 0b111111) << 5 | ((r >> 3) & 0b11111); } +void clearTop() { + display.fillRect(TOP_X, TOP_Y, TOP_WIDTH, TOP_HEIGHT, COLOR_BG); +} + +void clearMain() { + display.fillRect(MAIN_X, MAIN_Y, MAIN_WIDTH, MAIN_HEIGHT, COLOR_BG); +} + +void clearStatus() { + display.fillRect(STATUS_X, STATUS_Y, STATUS_WIDTH, STATUS_HEIGHT, COLOR_BG); +} + +void clearClock() { + display.fillRect(CLOCK_X, CLOCK_Y, CLOCK_WIDTH, CLOCK_HEIGHT, COLOR_BG); +} + void drawLayout() { display.fillScreen(COLOR_BG); - display.drawFastHLine(5, 4, 150, COLOR_DIVIDER); + display.drawFastHLine(5, 15, 150, COLOR_DIVIDER); display.drawFastHLine(5, 109, 150, COLOR_DIVIDER); display.drawFastHLine(5, 123, 150, COLOR_DIVIDER); - display.setCursor(CLOCK_X, CLOCK_Y); - display.print("13:12"); } // TODO: Error handling -String fetchDepartures() { +String fetchDepartures(String path) { std::unique_ptr https(new BearSSL::WiFiClientSecure); https->setInsecure(); HTTPClient client; - if (!client.begin(*https, "vrnp.beany.club", 443, "/departures?" QUERY_STRING)) { + if (!client.begin(*https, "vrnp.beany.club", 443, path)) { display.fillScreen(COLOR_BG); display.setCursor(0, 0); display.print("begin failed"); @@ -281,3 +392,70 @@ String fetchDepartures() { return client.getString(); } + +char mapUtf8CodepointToCP437German(uint32_t codepoint) { + switch (codepoint) { + case 0x00e4: return 0x84; // ä + case 0x00c4: return 0x8e; // Ä + case 0x00f6: return 0x94; // ö + case 0x00d6: return 0x99; // Ö + case 0x00fc: return 0x81; // ü + case 0x00dc: return 0x9a; // Ü + case 0x00df: return 0xe1; // ß + default: return '?'; + } +} + +/* +Maps german umlauts and sharp s from utf8 to cp437 encoding. +Other utf8 characters and malformed utf8 are replaced by '?'. +The resulting string is truncated to cp437StrSize. +*/ +void utf8ToCP437German(const char *utf8Str, char *cp437Str, size_t cp437StrSize) { + size_t utf8StrLength = strlen(utf8Str); + size_t utf8StrIndex = 0; + size_t cp437StrIndex = 0; + + char c; + + // One char at the end of the cp437Str is reserved to the terminating null + while (utf8StrIndex < utf8StrLength && cp437StrIndex + 1 < cp437StrSize) { + uint8_t cu0 = utf8Str[utf8StrIndex]; + + if (cu0 < 0x80) { + // ASCII maps to ASCII + c = cu0; + utf8StrIndex++; + } else if ((cu0 & 0b11100000) == 0b11000000) { + // codepoints encoded as two code units may contain german special characters + if (utf8StrIndex + 1 >= utf8StrLength) { + // if there's no 10xxxxxxx byte after this one, we've reached the end (malformed) + c = '?'; + break; + } else { + // otherwise decode the codepoint and map it to cp437 + uint8_t cu1 = utf8Str[utf8StrIndex + 1]; + uint32_t cp = (cu0 & 0x1f << 6) | cu1 & 0x3f; + c = mapUtf8CodepointToCP437German(cp); + utf8StrIndex += 2; + } + // for 3 and 4-code unit codepoints just put a ? and skip all their code units + // here we dont care whether the string is malformed + } else if ((cu0 & 0b11110000) == 0b11100000) { + c = '?'; + utf8StrIndex += 3; + } else if ((cu0 & 0b11111000) == 0b11110000) { + c = '?'; + utf8StrIndex += 4; + } else { + // catch all for malformed utf8 + c = '?'; + utf8StrIndex++; + } + + cp437Str[cp437StrIndex] = c; + cp437StrIndex++; + } + + cp437Str[cp437StrIndex] = '\0'; +} \ No newline at end of file