commit 0e4d39c47b4aef665984734e7443ac8a0ffcfb1a Author: MangaValk Date: Mon Oct 12 23:55:15 2026 +0200 Restructure into a single PlatformIO project with nano and esp32 targets - Shared BROSE9323 driver in lib/, ESPSERIAL selected via build_flags - Nano firmware in src/nano, ESP32 firmware in src/esp32 - Move WiFi/MQTT credentials to gitignored secrets.h (template provided) - Pin home-assistant-integration to 2.1.0 from the registry - Add esp32_usb environment for first flash / recovery - Add READMEs and CLAUDE.md Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..439439a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# PlatformIO +.pio/ +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch + +# Local credentials (template: src/esp32/secrets.example.h) +src/esp32/secrets.h + +# Claude Code local settings +.claude/settings.local.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..f2e4f92 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "platformio.platformio-ide" + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..03b8c16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# Flipdot display: notes for Claude + +A PlatformIO project with two firmware targets for an 84×19 Brose flipdot clock. +See README.md for the overview. + +## Layout and build + +- `platformio.ini` has the environments `nano`, `esp32` (OTA to 10.1.1.198) and + `esp32_usb` (COM11). The sources are split with `build_src_filter` (`src/nano/`, + `src/esp32/`). +- `lib/BROSE9323/` is the shared driver. Its behaviour depends on build flags set in + `platformio.ini`, not in the header: + - `ESPSERIAL` (ESP32): draw calls become serial commands. + - `FLIPDOT_PLCC_ADAPTER` (Nano, optional): alternative pinout. +- PlatformIO CLI is not on PATH. Run `~/.platformio/penv/Scripts/pio.exe run` (Git Bash) + or `$env:USERPROFILE\.platformio\penv\Scripts\pio.exe run` (PowerShell). +- If an ESP32 build fails with `No module named 'intelhex'`, run + `~/.platformio/penv/Scripts/python.exe -m pip install intelhex`. +- After any change, build both targets: `pio run -e nano -e esp32`. + +## Constraints + +- **Nano RAM is tight** (ATmega328P, 2 KB). The frame buffers are allocated with + `calloc`. Avoid `String`, large stack buffers and new globals in Nano code. Put constant + data in `PROGMEM`. +- The Nano and the ESP32 share one UART. On the ESP32, `Serial` is the display link, so + **don't print debug output on the ESP32's `Serial`**: the Nano would read it as + commands. The Nano ignores unknown command bytes, but text that starts with + s/S/F/D/P/B/R/T will be run as a command. +- `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it. + Add new secrets to `secrets.example.h` as placeholders. +- Hardware is not available here. Builds can be checked, but runtime behaviour can't. + +## Known quirks in the serial protocol (Nano `src/nano/main.cpp`) + +- `T` waits for more than 6 bytes before parsing, but `T225\n` has only 4 after the `T`. + It completes only when the next command arrives. +- The `T` value is stored with `EEPROM.update` in one byte, so values above 255 µs are + truncated after a reboot. +- `P` (print text) busy-waits for a `\n` and has no timeout. + +## Git + +- Remote: https://mangavalk.com/mangavalk/Flipdot-display.git, branch `main`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfbb337 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Flipdot Display + +Firmware for an 84 × 19 Brose flipdot display (three 28 × 19 panels) that runs as a +Home Assistant–controlled clock. + +The system uses two microcontrollers: + +``` + ┌──────────────┐ WiFi / MQTT ┌────────────────┐ UART 115200 ┌──────────────┐ GPIO ┌──────────────┐ + │ Home │ ◄─────────────► │ ESP32 │ ──────────────► │ Arduino Nano │ ───────► │ Brose │ + │ Assistant │ │ (controller) │ text commands │ (driver) │ │ flipdot │ + └──────────────┘ └───────┬────────┘ └──────────────┘ │ panels 84x19 │ + │ GPIO 13 └──────▲───────┘ + ▼ │ + ┌─────────────┐ display power │ + │ Relay │ ───────────────────────────────────────────────────┘ + └─────────────┘ +``` + +| Part | Folder | What it does | +|------|--------|--------------| +| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. | +| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps, exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. | +| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. | + +Because the driver is an `Adafruit_GFX` subclass on both boards, the ESP32 can use the +normal GFX API (`print`, `drawBitmap`, `fillScreen`, ...). Each call is sent to the +Nano and drawn there. + +## Repository layout + +``` +platformio.ini one PlatformIO project, multiple environments +lib/BROSE9323/ shared flipdot driver (used by both targets) +src/nano/ Arduino Nano firmware +src/esp32/ ESP32 firmware + secrets.example.h template for WiFi / MQTT credentials +archive/ old, unused sketches kept for reference +CLAUDE.md project notes for Claude Code +``` + +## Building and flashing + +Install [PlatformIO](https://platformio.org/) (the VS Code extension is easiest) and open +this folder. + +1. Copy `src/esp32/secrets.example.h` to `src/esp32/secrets.h` and fill in your WiFi and + MQTT details. `secrets.h` is gitignored. +2. Check the COM ports and the OTA IP in `platformio.ini`. +3. Build or upload: + +| Environment | Board | Upload method | +|-------------|-------|---------------| +| `nano` | Arduino Nano (ATmega328P) | USB (`COM10`) | +| `esp32` | ESP32 DevKit v1 | OTA over WiFi (`10.1.1.198`) | +| `esp32_usb` | ESP32 DevKit v1 | USB (`COM11`). Use it for the first flash or for recovery. | + +```sh +pio run # build nano + esp32 +pio run -e nano -t upload # flash the Nano +pio run -e esp32 -t upload # flash the ESP32 over the air +pio run -e esp32_usb -t upload # flash the ESP32 over USB +pio device monitor -e nano # serial monitor +``` + +In VS Code you can also pick the environment in the PlatformIO status bar. + +> **Tip:** The Nano and the ESP32 share a serial line. Disconnect the ESP32 TX → Nano RX +> wire before you flash the Nano over USB, or the upload may fail. + +## How it works + +1. **ESP32 boot.** The ESP32 connects to WiFi and starts ArduinoOTA. After a crash or + unexpected reset it stays in an *OTA panic window* for 15 seconds, so you can flash a + fix before the main code runs again. It then switches the display relay on, starts NTP + and connects to MQTT. +2. **Clock loop (every second).** + - **07:00–22:00:** Pikachu with a thought bubble showing `HH:MM`. A yawning Pikachu is + shown from 17:00. + - **22:00–23:30:** the clock is only drawn on the quarter hour. In between, the relay + turns the display off. + - **Night:** the screen is cleared and power is cut after 30 seconds. +3. **Home Assistant switches** (MQTT discovery): + - `FlipDot Display Clock`: off shows a Nintendo logo instead of the clock. + - `FlipDot Display Power`: off clears the display and cuts power through the relay. +4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer. + On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen + to fix dots that did not flip. + +The serial protocol is described in [src/nano/README.md](src/nano/README.md). diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..bfce2ca --- /dev/null +++ b/archive/README.md @@ -0,0 +1,8 @@ +# Archive + +Old sketches kept for reference. They are **not** built. The `.txt` extension keeps +PlatformIO from compiling them. + +- `SerialAPI_nano_old.cpp.txt`: an earlier version of the Nano serial firmware, from + before the EEPROM timing and the default flip time were added. +- `marquee_demo.cpp.txt`: a scrolling text (marquee) demo, including an umlaut test. diff --git a/archive/SerialAPI_nano_old.cpp.txt b/archive/SerialAPI_nano_old.cpp.txt new file mode 100644 index 0000000..838bb2a --- /dev/null +++ b/archive/SerialAPI_nano_old.cpp.txt @@ -0,0 +1,175 @@ +#include +#include "BROSE9323.h" +// fixes +#include +#include + +BROSE9323 display(84, 19, 28); + +uint32_t refresh_at; +const uint32_t REFRESH_TIME = 120000L; + +//#define DEBUG +void setup() { + Serial.begin(115200); + display.begin(); + delay(1000); + + display.fillScreen(1); + display.display(); + display.fillScreen(0); + uint32_t t1 = millis(); + display.display(); + uint32_t t2 = millis(); + Serial.println(t2 - t1); +} + +uint8_t hex2dec(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return 0; +} + +void loop() { + if (Serial.available()) { + char cmd = Serial.read(); + switch (cmd) { + // Ignore CR + case 0x0A: + break; + // Draw Bitmap (must be n x 8 wide) + case 'b': case 'B': //BxxyywwhhcBITMAP (xx/ww = 8 bit hex x position/width; yy/hh = 8 bit y position/height; c = 0/1 color; BITMAP = bytes as hex) + { + // May lead to buffer overflow, you may need to segment the bitmap into multiple frames + // Or just use the ser pixel function + while (Serial.available() < 9) { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t w = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + if (w % 4) { + Serial.println("ERR: Invalid bitmap width (not dividable by 4)"); + Serial.flush(); + break; + } + uint8_t h = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + if (h == 0) { + Serial.println("ERR: Invalid bitmap height (0)"); + Serial.flush(); + break; + } + uint8_t c = Serial.read() - '0'; + uint8_t b = 0; + for (uint8_t _y = 0; _y < h; _y++) { + for (uint8_t _x = 0; _x < w; _x++) { + if ((_x & 0x3) == 0) { + if (!Serial.available()) { + Serial.println("WARN: OUTOFMEM"); + } + while (!Serial.available()) { + delay(1); + } + b = hex2dec(Serial.read()); + } + if (b & (1 << (0x3 - (_x & 0x3)))) { + display.drawPixel(x + _x, y + _y, c); + } + } + } + break; + } + // Display + case 'd': case 'D': //D + { + display.display(); + break; + } + // Fill Screen + case 'f': case 'F': //Fc (c = 0/1 color) + { + while (Serial.available() <= 2) { + delay(1); + } + display.fillScreen(Serial.read() - '0'); + break; + } + // Print Text + case 'p': case 'P': // PxxyycsTEXT (xx = 8 bit hex x position; yy = 8 bit y position; c = 0/1 color; s = 1/2 size; TEXT = string, newline = escaped \\n) + { + delay(5); + while (Serial.available() <= 6) { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t c = Serial.read() - '0'; + display.setCursor(x, y); + display.setTextColor(c, !c); + display.setTextSize(Serial.read() - '0'); + while (true) { + if (!Serial.available()) { + delay(1); + } + if (Serial.peek() == '\n') break; + if (Serial.peek() == '\r') {Serial.read();continue;}; + char c = Serial.read(); + if (c == '\\' && Serial.peek() == 'n') { + display.println(); + Serial.read(); + } else { + display.write(c); + } + } + return; + } + // Return buffer (show pixels on console) + case 'r': case 'R': //R + { + display.printBuffer(); + Serial.flush(); + break; + } + // Set Pixel + case 'S': + display.setDirect(true); + case 's': // Sxxyyc (xx = 8 bit hex x position; y = 8 bit y position; c = 0/1 color) + { + while (Serial.available() <= 6) { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + bool color = hex2dec(Serial.read()); + display.drawPixel(x, y, color); + display.setDirect(false); + break; + } + // Set Timing + case 'T': case 't': // Tttt (ttt = decimal timing in us) + { + while (Serial.available() <= 6) { + delay(1); + } + uint16_t t = atoi(Serial.readStringUntil('\n').c_str()); + display.setTiming(t); + break; + } + default: +#ifdef DEBUG + Serial.print("0x"); + Serial.print(cmd >> 4, HEX); + Serial.print(cmd & 3, HEX); + Serial.println(" - command not known."); + Serial.flush(); +#endif + break; + } + } + // Refresh content every REFRESH_TIME (by default 2 min) + if (refresh_at < millis()) { + refresh_at = millis() + REFRESH_TIME; + display.display(true); + } +} diff --git a/archive/marquee_demo.cpp.txt b/archive/marquee_demo.cpp.txt new file mode 100644 index 0000000..15d5799 --- /dev/null +++ b/archive/marquee_demo.cpp.txt @@ -0,0 +1,42 @@ +#include +#include "BROSE9323.h" +// fixes +#include +#include + +BROSE9323 display(84, 19, 28); + +/* + UMLAUT: + Ä \x8E + ä \x84 + Ö \x99 + ö \x94 + Ü \x9A + ü \x81 + ß \xE0 +*/ +// const char text[] = "This is a test. Testing \x9Aml\x8Aute. "; +const char text[] = "This is a test. Testing Ümläute. "; +const uint8_t textsize = 2; + +void setup() +{ + display.begin(); + delay(2000); + display.fillScreen(0); + display.setTextSize(textsize); + display.setTextWrap(false); + display.setTextColor(1, 0); + //display.setDirect(true); //when using setTextColor(1, 0) we don't need a separate display() and the text scrolls more evenly (on *328*) +} + +void loop() +{ + for (int16_t x = display.width(); x > -((int16_t)strlen(text) * textsize * 6); x--) + { + display.setCursor(x, 0); + display.print(text); + display.display(); + } +} diff --git a/lib/BROSE9323/BROSE9323.cpp b/lib/BROSE9323/BROSE9323.cpp new file mode 100644 index 0000000..b174c40 --- /dev/null +++ b/lib/BROSE9323/BROSE9323.cpp @@ -0,0 +1,325 @@ +#include "BROSE9323.h" + +BROSE9323::BROSE9323(uint8_t w, uint8_t h, uint8_t pw, uint16_t ft) : Adafruit_GFX(w, h), + _flip_time(ft), + _panel_width(pw), + _buffer_width((w + 7) / 8), + _buffer_size(_buffer_width * h) +{ + // Buffer width should fit at least [width] bits + //_buffer_width = (w + 7) / 8; + + //_buffer_size = _buffer_width * h; + +#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__) + _old_buffer = (uint8_t *)calloc(_buffer_size, sizeof(uint8_t)); +#endif + _new_buffer = (uint8_t *)calloc(_buffer_size, sizeof(uint8_t)); +} + +#ifdef ESPSERIAL +void BROSE9323::begin(Stream *s) +{ + stream = s; + fillScreen(0); + setCursor(0, 4); + print("ESP Connected"); + display(); +} +#else +void BROSE9323::begin(void) +{ + digitalWrite(ADDR_0, 1); + digitalWrite(ADDR_1, 1); + digitalWrite(ADDR_2, 1); + digitalWrite(COL_0, 1); + digitalWrite(COL_1, 1); + digitalWrite(COL_2, 1); + digitalWrite(COL_3, 1); + digitalWrite(COL_4, 1); + digitalWrite(ROW_0, 1); + digitalWrite(ROW_1, 1); + digitalWrite(ROW_2, 1); + digitalWrite(ROW_3, 1); + digitalWrite(ROW_4, 1); + digitalWrite(ROW_RESET, 1); + digitalWrite(ROW_SET, 1); + digitalWrite(COL_DATA, 1); + digitalWrite(ENABLE, 1); + pinMode(ADDR_0, OUTPUT); + pinMode(ADDR_1, OUTPUT); + pinMode(ADDR_2, OUTPUT); + pinMode(COL_0, OUTPUT); + pinMode(COL_1, OUTPUT); + pinMode(COL_2, OUTPUT); + pinMode(COL_3, OUTPUT); + pinMode(COL_4, OUTPUT); + pinMode(ROW_0, OUTPUT); + pinMode(ROW_1, OUTPUT); + pinMode(ROW_2, OUTPUT); + pinMode(ROW_3, OUTPUT); + pinMode(ROW_4, OUTPUT); + pinMode(ROW_RESET, OUTPUT); + pinMode(ROW_SET, OUTPUT); + pinMode(COL_DATA, OUTPUT); + pinMode(ENABLE, OUTPUT); + _setData(1); + _setData(0); + fillScreen(1); + display(); + fillScreen(0); + drawBitmap((width() - 72) / 2, (height() - 16) / 2, _hannio_splash, 72, 16, 1); + display(); +} +#endif + +void BROSE9323::display(bool force) +{ +#ifdef ESPSERIAL + stream->print("D\n"); + stream->flush(); +#else + if (_direct_mode) + return; + for (uint8_t x = 0; x < width(); x++) + { + _selectPanel(x / _panel_width); + + _selectColumn(x % _panel_width); + + for (uint8_t y = 0; y < height(); y++) + { + bool b = _new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)); +#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__) + if (!force && (bool)(_old_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) == b) + { + continue; + } +#endif + + _selectRow(y); + + _setData(_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))); + + _strobe(); + } + } +#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__) + // Store currently displayed content in old buffer + memcpy(_old_buffer, _new_buffer, _buffer_size); +#endif +#endif +} + +void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color) +{ +#ifdef ESPSERIAL + if (y >= height() || y < 0 || x >= width() || x < 0) + return; + + char s[10]; + sprintf(s, "%c%02x%02x%d\n", _direct_mode ? 'S' : 's', x, y, (bool)color); + stream->print(s); + stream->flush(); +#else + if (y >= height() || y < 0 || x >= width() || x < 0) + return; + if (color) + { + if (_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) + { + return; + } + _new_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7); + } + else + { + if (~_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) + { + return; + } + _new_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7)); + } + if (_direct_mode) + { + _selectPanel(x / _panel_width); + + _selectColumn(x % _panel_width); + + _selectRow(y); + + _setData(_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))); + + _strobe(); +#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__) + if (color) + { + if (_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) + { + return; + } + _new_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7); + } + else + { + if (~_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) + { + return; + } + _new_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7)); + } +#endif + } +#endif +} + +void BROSE9323::fillScreen(uint16_t color) +{ +#ifdef ESPSERIAL + stream->print(color ? "F1\n" : "F0\n"); +#else + memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size); + if (_direct_mode) + { + _setData(color); + for (uint8_t x = 0; x < width(); x++) + { + if (x % _panel_width == 0) + { + _selectPanel(x / _panel_width); + } + + _selectColumn(x % _panel_width); + + for (uint8_t y = 0; y < height(); y++) + { + _selectRow(y); + + _strobe(); + } + } +#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__) + memset(_old_buffer, color ? 0xFF : 0x00, _buffer_size); +#endif + } +#endif +} + +void BROSE9323::setTiming(uint16_t t) +{ +#ifdef ESPSERIAL + stream->write('T'); + stream->print(t); + stream->write('\n'); +#else + _flip_time = t; +#endif +} + +void BROSE9323::setDirect(bool d) +{ + _direct_mode = d; +} + +#ifndef ESPSERIAL +void BROSE9323::printBuffer(void) +{ + for (uint8_t y = 0; y < height(); y++) + { + for (uint8_t x = 0; x < width(); x++) + { + Serial.write(_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)) ? '*' : ' '); + } + Serial.println(); + } +} + +void BROSE9323::_selectColumn(uint8_t col) +{ + if (_active_col == col) + return; + _active_col = col; + col += 1 + col / 7; + digitalWrite(COL_0, col & 1); + digitalWrite(COL_1, col & 2); + digitalWrite(COL_2, col & 4); + digitalWrite(COL_3, col & 8); + digitalWrite(COL_4, col & 16); +} + +void BROSE9323::_selectPanel(uint8_t panel) +{ + if (_active_panel == panel) + return; + _active_panel = panel; + digitalWrite(ADDR_0, panel & 1); + digitalWrite(ADDR_1, panel & 2); + digitalWrite(ADDR_2, panel & 4); +} + +void BROSE9323::_selectRow(uint8_t row) +{ + if (_active_row == row) + return; + _active_row = row; + row += row / 7; + digitalWrite(ROW_0, row & 1); + digitalWrite(ROW_1, row & 2); + digitalWrite(ROW_2, row & 4); + digitalWrite(ROW_3, row & 8); + digitalWrite(ROW_4, row & 16); +} + +void BROSE9323::_setData(bool data) +{ + if (_active_data == data) + return; + _active_data = data; + if (data) + { + digitalWrite(ROW_RESET, 1); + digitalWrite(ROW_SET, 0); + digitalWrite(COL_DATA, 0); + } + else + { + digitalWrite(ROW_SET, 1); + digitalWrite(ROW_RESET, 0); + digitalWrite(COL_DATA, 1); + } +} + +void BROSE9323::_strobe(void) +{ + digitalWrite(ENABLE, 0); + delayMicroseconds(_flip_time); + digitalWrite(ENABLE, 1); +} +#endif + +size_t BROSE9323::print(const char *ifsh) +{ + // replace these characters + /* + UMLAUT: + Ä \x8E + ä \x84 + Ö \x99 + ö \x94 + Ü \x9A + ü \x81 + ß \xE0 +*/ + String temp = ifsh; + temp.replace("Ä", "\x8E"); + temp.replace("ä", "\x84"); + temp.replace("Ö", "\x99"); + temp.replace("ö", "\x94"); + temp.replace("Ü", "\x9A"); + temp.replace("ü", "\x81"); + temp.replace("ß", "\xE0"); + + ifsh = temp.c_str(); + + return Adafruit_GFX::print(ifsh); +} \ No newline at end of file diff --git a/lib/BROSE9323/BROSE9323.h b/lib/BROSE9323/BROSE9323.h new file mode 100644 index 0000000..c158738 --- /dev/null +++ b/lib/BROSE9323/BROSE9323.h @@ -0,0 +1,100 @@ +#ifndef BROSE9323_H +#define BROSE9323_H + +#include + +//#define FLIPDOT_PLCC_ADAPTER +// ESPSERIAL is set per target via build_flags in platformio.ini (-D ESPSERIAL for the ESP32) + +const uint8_t _hannio_splash[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x70, + 0x3e, 0x00, 0x39, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x70, 0xff, 0x00, 0x39, 0xff, 0xff, 0xff, 0xff, + 0xfe, 0x71, 0xf7, 0xc0, 0x39, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x73, 0xc1, 0xc0, 0x38, 0x1f, 0x87, + 0xf0, 0x7f, 0x06, 0x73, 0x80, 0xe0, 0x38, 0x0f, 0x01, 0xc0, 0x3c, 0x02, 0x77, 0x7f, 0x60, 0x38, + 0xc7, 0x78, 0xc6, 0x1c, 0x60, 0x77, 0x7f, 0x70, 0x39, 0xe3, 0xfc, 0xcf, 0x9c, 0xf8, 0x76, 0x00, + 0x30, 0x39, 0xf3, 0xfc, 0xcf, 0x9c, 0xf8, 0x77, 0x7f, 0x70, 0x39, 0xf3, 0x80, 0xcf, 0x9c, 0xf8, + 0x77, 0x7f, 0x60, 0x39, 0xf3, 0x00, 0xcf, 0x9c, 0xf8, 0x73, 0x80, 0xe0, 0x39, 0xf2, 0x3c, 0xcf, + 0x9c, 0xf8, 0x73, 0xc1, 0xc0, 0x39, 0xf2, 0x7c, 0xcf, 0x9c, 0xf8, 0x71, 0xf7, 0xc0, 0x39, 0xf2, + 0x3c, 0xcf, 0x9c, 0xf8, 0x70, 0xff, 0x80, 0x39, 0xf3, 0x00, 0xcf, 0x9c, 0xf8, 0x70, 0x3e, 0x00}; + +class BROSE9323 : public Adafruit_GFX +{ +private: + uint16_t _flip_time; + const uint8_t _panel_width; + const uint8_t _buffer_width; + const uint16_t _buffer_size; + uint8_t *_old_buffer = NULL; + uint8_t *_new_buffer = NULL; + bool _direct_mode = false; + +#ifdef ESPSERIAL + Stream *stream; +#else + uint8_t _active_panel = 255; + uint8_t _active_col = 255; + uint8_t _active_row = 255; + bool _active_data; + +#ifndef FLIPDOT_PLCC_ADAPTER + const uint8_t ENABLE = 7, + ADDR_0 = 3, + ADDR_1 = 5, + ADDR_2 = 6, + COL_DATA = 4, + COL_0 = 8, + COL_1 = 10, + COL_2 = 9, + COL_3 = 11, + COL_4 = 12, + ROW_0 = A3, + ROW_1 = A4, + ROW_2 = A2, + ROW_3 = A0, + ROW_4 = A1, + ROW_RESET = 13, + ROW_SET = A5; +#else + const uint8_t RELAY = 2, + ENABLE = 3, + ADDR_0 = 7, + ADDR_1 = 6, + ADDR_2 = 5, + COL_DATA = 4, + COL_0 = 12, + COL_1 = 11, + COL_2 = 10, + COL_3 = 9, + COL_4 = 8, + ROW_0 = 13, //1 A1 CHECK + ROW_1 = A0, //2 A2 + ROW_2 = A1, //4 A3 + ROW_3 = A2, //8 13 CHECK + ROW_4 = A3, //F A0 + ROW_RESET = A4, + ROW_SET = A5; +#endif + + void _selectColumn(uint8_t); + void _selectPanel(uint8_t); + void _selectRow(uint8_t); + void _setData(bool); + void _strobe(void); +#endif +public: + BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t ft = 280); +#ifdef ESPSERIAL + void begin(Stream * = &Serial); +#endif + void begin(void); + void display(bool force = false); + void drawPixel(int16_t x, int16_t y, uint16_t color); + void fillScreen(uint16_t); + void setDirect(bool); + void setTiming(uint16_t); +#ifndef ESPSERIAL + void printBuffer(void); +#endif + size_t print(const char *ifsh); +}; +#endif //BROSE9323_H diff --git a/lib/BROSE9323/README.md b/lib/BROSE9323/README.md new file mode 100644 index 0000000..4837999 --- /dev/null +++ b/lib/BROSE9323/README.md @@ -0,0 +1,25 @@ +# BROSE9323 driver + +An `Adafruit_GFX` driver for Brose flipdot panels, shared by both firmware targets. +The constructor is `BROSE9323(width, height, panel_width, flip_time_us = 280)`. This +project uses `BROSE9323 display(84, 19, 28)`. + +## Build modes + +| Flag | Used by | Behaviour | +|------|---------|-----------| +| *(none)* | Nano | Drives the panel through GPIO. Keeps an old and a new frame buffer and flips only the dots that changed. | +| `-D ESPSERIAL` | ESP32 | Holds no buffer. `drawPixel`, `fillScreen`, `display` and `setTiming` are sent as serial commands to the Stream passed to `begin(&Serial)`. | +| `-D FLIPDOT_PLCC_ADAPTER` | Nano (optional) | Uses the pin mapping of the PLCC adapter board. | + +The flags are set per environment in `platformio.ini` through `build_flags`. Don't +`#define` them in the header. + +## Notes + +- In direct mode (`setDirect(true)`) every pixel is flipped right away, without a call to + `display()`. +- `print()` maps German umlauts (ÄäÖöÜüß) to the code page 437 glyphs used by the GFX + font. +- On an ATmega168 the old buffer is left out to save RAM. Every `display()` call then + flips all dots. diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..9972f9e --- /dev/null +++ b/platformio.ini @@ -0,0 +1,59 @@ +; Flipdot display - one PlatformIO project, two firmware targets. +; +; nano Arduino Nano that drives the Brose flipdot panels directly +; esp32 ESP32 "brain" (WiFi, NTP clock, Home Assistant) - OTA upload +; esp32_usb Same ESP32 firmware, flashed over USB (first flash / recovery) +; +; Build all: pio run +; Build one: pio run -e nano +; Upload one: pio run -e nano -t upload +; Serial monitor: pio device monitor -e nano +; +; https://docs.platformio.org/page/projectconf.html + +[platformio] +default_envs = nano, esp32 + +[env] +framework = arduino +monitor_speed = 115200 +lib_deps = + adafruit/Adafruit GFX Library@^1.10.12 + +; --------------------------------------------------------------------------- +; Arduino Nano - display driver +; --------------------------------------------------------------------------- +[env:nano] +platform = atmelavr +board = nanoatmega328 +board_build.mcu = atmega328p +board_build.f_cpu = 16000000L +build_src_filter = + +; Uncomment when using the PLCC adapter board (different pinout, see lib/BROSE9323/BROSE9323.h) +; build_flags = -D FLIPDOT_PLCC_ADAPTER +upload_port = COM10 +monitor_port = COM10 + +; --------------------------------------------------------------------------- +; ESP32 - controller, talks to the Nano over serial +; --------------------------------------------------------------------------- +[env:esp32] +platform = espressif32 +board = esp32doit-devkit-v1 +build_src_filter = + +; ESPSERIAL turns the BROSE9323 driver into a serial client: every draw call is +; sent to the Nano as a text command instead of toggling GPIO pins. +build_flags = -D ESPSERIAL +lib_deps = + ${env.lib_deps} + https://github.com/robjen/GFX_fonts.git + dawidchyrzynski/home-assistant-integration@^2.1.0 + knolleary/PubSubClient@^2.8.0 +upload_protocol = espota +upload_port = 10.1.1.198 +monitor_port = COM11 + +[env:esp32_usb] +extends = env:esp32 +upload_protocol = esptool +upload_port = COM11 diff --git a/src/esp32/README.md b/src/esp32/README.md new file mode 100644 index 0000000..a963afe --- /dev/null +++ b/src/esp32/README.md @@ -0,0 +1,45 @@ +# ESP32: controller + +PlatformIO environments: `esp32` (OTA) and `esp32_usb` (USB) + +The ESP32 decides what is shown on the display. It never touches the panel itself. The +BROSE9323 driver is built with `-D ESPSERIAL`, so every GFX call is sent over `Serial` +(115200 baud) to the Nano. + +## Files + +| File | Contents | +|------|----------| +| `main.cpp` | setup, OTA, clock / power logic, Home Assistant callbacks | +| `libs.h` | includes and global objects (display, MQTT, WiFi, pins, timezone) | +| `bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) | +| `secrets.example.h` | template for `secrets.h` (WiFi and MQTT credentials, **gitignored**) | + +## Features + +- **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`. +- **OTA panic window:** after a crash or watchdog reset, the ESP32 runs only the OTA + handler for 15 seconds before it starts the main code. A firmware that crashes on boot + can still be replaced over the air. +- **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). +- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches: + - `FlipDotDisplayClock`: clock on, or the Nintendo logo when off + - `FlipDotDisplayPower`: turns the display off through the relay +- **Relay on GPIO 13** switches the display's power supply. `LOW` turns it on. +- **Status LED** (built-in) blinks fast when WiFi is down and slowly when connected. It is + off at night or when the display is powered off. + +## Schedule + +| Time | Display | +|------|---------| +| 07:00 – 17:00 | Pikachu and the clock, updated every minute | +| 17:00 – 22:00 | Yawning Pikachu and the clock | +| 22:00 – 23:30 | Clock drawn only on the quarter hour; power is off in between | +| night | Screen cleared, power off | + +## Adding bitmaps + +Convert an image with [image2cpp](https://javl.github.io/image2cpp/) to "Arduino code", +horizontal, 1 bit per pixel. Paste the array into `bitmaps.h` and draw it with +`display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is 84 × 19 pixels. diff --git a/src/esp32/bitmaps.h b/src/esp32/bitmaps.h new file mode 100644 index 0000000..39ee00b --- /dev/null +++ b/src/esp32/bitmaps.h @@ -0,0 +1,89 @@ +#include + +// 'Nintendo logo', 84x19px +const unsigned char nintendo_bitmap_Nintendo_new[] PROGMEM = { + 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x80, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x67, 0x8e, 0x70, 0x00, + 0x70, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x60, 0x67, 0x8e, 0x70, 0x00, 0x70, 0x00, 0x00, 0x00, 0x1c, + 0x00, 0x60, 0xc7, 0x8e, 0x07, 0x39, 0xfc, 0xfc, 0x73, 0x83, 0xdc, 0x7c, 0x30, 0xc7, 0x4e, 0x77, + 0xfc, 0x71, 0xee, 0x7f, 0xc7, 0xfc, 0xfe, 0x30, 0xc7, 0x6e, 0x77, 0x8e, 0x73, 0xc7, 0x78, 0xef, + 0x3d, 0xc7, 0x30, 0xc7, 0x2e, 0x77, 0x0e, 0x73, 0xff, 0x70, 0xef, 0x3d, 0xc7, 0x30, 0xc7, 0x3e, + 0x77, 0x0e, 0x73, 0xc0, 0x70, 0xef, 0x3d, 0xc7, 0x30, 0xc7, 0x1e, 0x77, 0x0e, 0x73, 0xc7, 0x70, + 0xef, 0x3d, 0xc7, 0x30, 0xc7, 0x0e, 0x77, 0x0e, 0x71, 0xee, 0x70, 0xe7, 0xfc, 0xfe, 0x30, 0x67, + 0x0e, 0x77, 0x0e, 0x70, 0xfc, 0x70, 0xe3, 0xdc, 0x7c, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, + 0x00}; + +// 'nintendo border', 84x19px +const unsigned char nintendo_bitmap_nintendo_border[] PROGMEM = { + 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x80, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x60, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x60, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x60, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x0f, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, + 0x00}; + +// 'panda', 17x18px +const unsigned char nintendo_bitmap_panda_inverted[] PROGMEM = { + 0xfe, 0x7f, 0x80, 0xfc, 0x3c, 0x80, 0xfc, 0x38, 0x00, 0xdd, 0xfe, 0x00, 0xbb, 0xff, 0x80, 0x73, + 0xff, 0x80, 0x77, 0x3f, 0x80, 0x76, 0x33, 0x00, 0x76, 0x73, 0x00, 0x33, 0xfb, 0x00, 0x31, 0x8e, + 0x00, 0x90, 0xdc, 0x00, 0x80, 0xf0, 0x00, 0x80, 0x00, 0x00, 0xc4, 0x78, 0x80, 0xe4, 0x70, 0x80, + 0xfe, 0x11, 0x80, 0xff, 0x1f, 0x80}; +const unsigned char nintendo_bitmap_panda[] PROGMEM = { + 0x01, 0x80, 0x00, 0x03, 0xc3, 0x00, 0x03, 0xc7, 0x80, 0x22, 0x01, 0x80, 0x44, 0x00, 0x00, 0x8c, + 0x00, 0x00, 0x88, 0xc0, 0x00, 0x89, 0xcc, 0x80, 0x89, 0x8c, 0x80, 0xcc, 0x04, 0x80, 0xce, 0x71, + 0x80, 0x6f, 0x23, 0x80, 0x7f, 0x0f, 0x80, 0x7f, 0xff, 0x80, 0x3b, 0x87, 0x00, 0x1b, 0x8f, 0x00, + 0x01, 0xee, 0x00, 0x00, 0xe0, 0x00}; + +// 'yawning_pikachu', 35x19px +const unsigned char yawning_pikachu_bitmap_yawning_pikachu [] PROGMEM = { + 0xff, 0xfc, 0x07, 0xff, 0xe0, 0xff, 0xe3, 0xf8, 0xff, 0xe0, 0xfe, 0x1f, 0xff, 0x0f, 0xe0, 0xf9, + 0xff, 0xff, 0xf3, 0xe0, 0xe7, 0xff, 0xff, 0xfc, 0xe0, 0xdf, 0x7f, 0xff, 0xdf, 0x60, 0x9f, 0x73, + 0xf9, 0xdf, 0x20, 0x0c, 0xcf, 0xbe, 0x66, 0x00, 0x02, 0xff, 0xff, 0xe8, 0x00, 0xfe, 0xff, 0x1f, + 0xef, 0xe0, 0xfd, 0x9e, 0x0f, 0x37, 0xe0, 0xfd, 0x0e, 0xee, 0x17, 0xe0, 0xfd, 0x0e, 0xee, 0x17, + 0xe0, 0xfd, 0x9e, 0xe3, 0x37, 0xe0, 0xfd, 0xff, 0x0d, 0xf7, 0xe0, 0xfe, 0xff, 0xee, 0xef, 0xe0, + 0xfe, 0xff, 0xf7, 0x6f, 0xe0, 0xfe, 0x7f, 0xfb, 0x8f, 0xe0, 0xfe, 0xfe, 0x0b, 0xaf, 0xe0 +}; + +// 'yawning_pikachu_black_border', 35x19px +const unsigned char yawning_pikachu_bitmap_yawning_pikachu_black_border [] PROGMEM = { + 0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07, + 0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x7f, 0xff, 0xdf, 0x80, 0x7f, 0x73, + 0xf9, 0xdf, 0xc0, 0xfe, 0xcf, 0xbe, 0x6f, 0xe0, 0xfd, 0xff, 0xff, 0xf7, 0xe0, 0x01, 0xff, 0x1f, + 0xf0, 0x00, 0x03, 0x9e, 0x0f, 0x38, 0x00, 0x03, 0x0e, 0xee, 0x18, 0x00, 0x03, 0x0e, 0xee, 0x18, + 0x00, 0x03, 0x9e, 0xe3, 0x38, 0x00, 0x03, 0xff, 0x0d, 0xf8, 0x00, 0x01, 0xff, 0xee, 0xf0, 0x00, + 0x01, 0xff, 0xf7, 0x70, 0x00, 0x00, 0x7f, 0xfb, 0x80, 0x00, 0x01, 0xfe, 0x0b, 0xb0, 0x00 +}; + +// 'clock thought bubble', 46x19px +const unsigned char clock_thought_bubble_bitmap_clock_thought_bubble [] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x80, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, + 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x40, 0x20, 0x00, 0x00, 0x00, 0x00, 0x40, 0x10, 0x00, 0x00, 0x00, 0x00, 0x80, 0x08, 0x3f, + 0xff, 0xff, 0xff, 0x00, 0x10, 0x40, 0x00, 0x00, 0x00, 0x00, 0x21, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, + 0x00, 0x00 +}; + +// 'pikachu_black_border', 35x19px +const unsigned char pikachu_bitmap_pikachu_black_border [] PROGMEM = { + 0x00, 0x03, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, 0x07, + 0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, 0xff, 0x00, 0x3f, 0x73, 0xf9, 0xdf, 0x80, 0x7f, 0x69, + 0xf4, 0xdf, 0xc0, 0xfe, 0xe1, 0xf0, 0xef, 0xe0, 0xfd, 0xf3, 0xf9, 0xf7, 0xe0, 0x01, 0xff, 0xbf, + 0xf0, 0x00, 0x03, 0x9f, 0xff, 0x38, 0x00, 0x03, 0x0f, 0xfe, 0x18, 0x00, 0x03, 0x0f, 0xfe, 0x18, + 0x00, 0x03, 0x9d, 0xb7, 0x38, 0x00, 0x03, 0xfe, 0x4f, 0xf8, 0x00, 0x01, 0xff, 0xff, 0xf0, 0x00, + 0x01, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x7f, 0xff, 0x80, 0x00, 0x01, 0xfe, 0x0f, 0xf0, 0x00 +}; diff --git a/src/esp32/libs.h b/src/esp32/libs.h new file mode 100644 index 0000000..cb39b11 --- /dev/null +++ b/src/esp32/libs.h @@ -0,0 +1,71 @@ +#include +#include "BROSE9323.h" +#include "bitmaps.h" + +// WiFi / MQTT credentials (gitignored, copy secrets.example.h -> secrets.h) +#if __has_include("secrets.h") +#include "secrets.h" +#else +#error "Missing src/esp32/secrets.h - copy src/esp32/secrets.example.h to secrets.h and fill it in" +#endif + +// fixes +#include +#include +// fonts +#include +#include +// #include + +#include +#include +#include +#include +#include + +// wdt? +#include +#include + +// hassio +#include + +byte mac[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28}; + +// EthernetClient client; +WiFiClient client; +HADevice device(mac, sizeof(mac)); +HAMqtt mqtt(client, device); + +HASwitch flipDotDisplay("FlipDotDisplayClock"); +bool hassioDisplayStatus = true; + +HASwitch powerSwitch("FlipDotDisplayPower"); +bool power = true; + +const int led1 = BUILTIN_LED; // Pin of the LED +const int relay = 13; // Pin of the relay + +int hour = 0; +int minute = 0; +int second = 0; + +// +WiFiMulti wifiMulti; + +const char *ssid = WIFI_SSID; +const char *password = WIFI_PASSWORD; + +BROSE9323 display(84, 19, 28); + +uint32_t next_display; + +// ntp +#define MYTZ "CET-1CEST,M3.5.0,M10.5.0/3" +const char *ntpServer = "pool.ntp.org"; +const long gmtOffset_sec = 3600; +const int daylightOffset_sec = 3600; +// const int daylightOffset_sec = 0; + +bool wifiStatus = false; +bool otaPanicMode = true; diff --git a/src/esp32/main.cpp b/src/esp32/main.cpp new file mode 100644 index 0000000..de96a8e --- /dev/null +++ b/src/esp32/main.cpp @@ -0,0 +1,514 @@ +#include "libs.h" + +void toggleLED(void *parameter); + +uint32_t power_off_delay; +int triggered_power_off = false; +int turn_hassio_power_off = false; +void turnPowerOff(uint32_t delay = 30000, bool _turn_hassio_power_off = false) +{ + if(triggered_power_off == false) + { + // start new countdown + turn_hassio_power_off = _turn_hassio_power_off; + power_off_delay = millis() + delay; + + triggered_power_off = true; + } +} + +void updatePower() +{ + if(millis() > power_off_delay && triggered_power_off == true) + { + // finished countdown + triggered_power_off = false; + + // OFF + digitalWrite(relay, HIGH); + + if(turn_hassio_power_off) + { + power = false; + powerSwitch.setState(false); + } + } +} + +void onSwitchStateChanged(bool state, HASwitch *s) +{ + hassioDisplayStatus = (state ? HIGH : LOW); +} + +void onPowerSwitchStateChanged(bool state, HASwitch *s) +{ + power = (state ? HIGH : LOW); + + if(power == false) + { + display.fillScreen(0); + display.display(); + + delay(1000); + + turnPowerOff(0); + + return; + } +} + +void print_reset_reason(RESET_REASON reason) +{ + switch (reason) + { + case POWERON_RESET: + Serial.println("POWERON_RESET"); + break; /**<1, Vbat power on reset*/ + case 3: + Serial.println("SW_RESET"); + break; /**<3, Software reset digital core*/ + case 4: + Serial.println("OWDT_RESET"); + break; /**<4, Legacy watch dog reset digital core*/ + case 5: + Serial.println("DEEPSLEEP_RESET"); + break; /**<5, Deep Sleep reset digital core*/ + case 6: + Serial.println("SDIO_RESET"); + break; /**<6, Reset by SLC module, reset digital core*/ + case 7: + Serial.println("TG0WDT_SYS_RESET"); + break; /**<7, Timer Group0 Watch dog reset digital core*/ + case 8: + Serial.println("TG1WDT_SYS_RESET"); + break; /**<8, Timer Group1 Watch dog reset digital core*/ + case 9: + Serial.println("RTCWDT_SYS_RESET"); + break; /**<9, RTC Watch dog Reset digital core*/ + case 10: + Serial.println("INTRUSION_RESET"); + break; /**<10, Instrusion tested to reset CPU*/ + case 11: + Serial.println("TGWDT_CPU_RESET"); + break; /**<11, Time Group reset CPU*/ + case SW_CPU_RESET: + Serial.println("SW_CPU_RESET"); + break; /**<12, Software reset CPU*/ + case 13: + Serial.println("RTCWDT_CPU_RESET"); + break; /**<13, RTC Watch dog Reset CPU*/ + case EXT_CPU_RESET: + Serial.println("EXT_CPU_RESET"); + break; /**<14, for APP CPU, reseted by PRO CPU*/ + case 15: + Serial.println("RTCWDT_BROWN_OUT_RESET"); + break; /**<15, Reset when the vdd voltage is not stable*/ + case 16: + Serial.println("RTCWDT_RTC_RESET"); + break; /**<16, RTC Watch dog reset digital core and rtc module*/ + default: + Serial.println("NO_MEAN"); + } +} + +bool checkResetStatus() +{ + Serial.println("CPU0 reset reason: "); + RESET_REASON reason0 = rtc_get_reset_reason(0); + print_reset_reason(reason0); + + // if(reason0 == SW_CPU_RESET) + // return true; + + Serial.println("CPU1 reset reason: "); + RESET_REASON reason1 = rtc_get_reset_reason(1); + print_reset_reason(reason1); + + // if(reason1 == SW_CPU_RESET) + // return true; + + if (reason0 != POWERON_RESET && reason1 != EXT_CPU_RESET) + return true; + + return false; +} + +void setup() +{ + otaPanicMode = true; + pinMode(led1, OUTPUT); + + Serial.begin(115200); + + WiFi.setHostname("FlipDotDisplay"); + wifiMulti.addAP(ssid, password); + + Serial.println("Booting"); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + while (WiFi.waitForConnectResult() != WL_CONNECTED) + { + Serial.println("Connection Failed! Rebooting..."); + delay(5000); + ESP.restart(); + } + + ArduinoOTA + .onStart([]() + { + String type; + if (ArduinoOTA.getCommand() == U_FLASH) + type = "sketch"; + else // U_SPIFFS + type = "filesystem"; + + // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end() + // USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD + // Serial.println("Start updating " + type); + + // if (!otaPanicMode) + // { + // display.begin(&Serial); + // display.fillScreen(0); + // display.setTextSize(1); + // display.setCursor(0, 0); + // display.print("ota starting!"); + // display.display(); + // } + }) + .onEnd([]() + { + // USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD + // Serial.println("\nEnd"); + }) + .onProgress([](unsigned int progress, unsigned int total) + { + // USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD + // USE_SERIAL.printf("Progress: %u%%\r", (progress / (total / 100))); + // if (!otaPanicMode) + // { + // display.begin(&Serial); + // display.fillScreen(0); + // display.setTextSize(1); + // display.setCursor(0, 0); + // display.printf("ota: %u%%\r", (progress / (total / 100))); + // display.display(); + // } + }) + .onError([](ota_error_t error) + { + // // USE_SERIAL.write(0x0C); //CLEAR SCREEN CMD + // Serial.printf("Error[%u]: ", error); + // if (error == OTA_AUTH_ERROR) + // Serial.println("Auth Failed"); + // else if (error == OTA_BEGIN_ERROR) + // Serial.println("Begin Failed"); + // else if (error == OTA_CONNECT_ERROR) + // Serial.println("Connect Failed"); + // else if (error == OTA_RECEIVE_ERROR) + // Serial.println("Receive Failed"); + // else if (error == OTA_END_ERROR) + // Serial.println("End Failed"); + }); + + ArduinoOTA.begin(); + + do + { + ArduinoOTA.handle(); + + digitalWrite(led1, HIGH); + + vTaskDelay(50 / portTICK_PERIOD_MS); + + // Turn the LED off + digitalWrite(led1, LOW); + + vTaskDelay(50 / portTICK_PERIOD_MS); + + } while (checkResetStatus() && millis() < 1000 * 15); + otaPanicMode = false; + + pinMode(relay, OUTPUT); + digitalWrite(relay, LOW); + + delay(4000); + + display.begin(&Serial); + // display.setTiming(200); + // display.setTiming(180); + // display.setTiming(220); // works best so far + display.setTiming(225); + + // delay(4000); + display.fillScreen(0); + display.setCursor(0, 0); + display.setTextSize(1); + display.println("Connecting..."); + display.display(); + + // display.setFont(&FreeSerif12pt7b); + // display.setFont(&FreeSerifItalic12pt7b); + + //init and get the time + // configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); + configTzTime(MYTZ, ntpServer); + + xTaskCreate( + toggleLED, // Function that should be called + "Toggle LED", // Name of the task (for debugging) + 1000, // Stack size (bytes) + NULL, // Parameter to pass + 1, // Task priority + NULL // Task handle + ); + + next_display = millis() / 1000 * 1000; + + // // TEST CODE + // do{ + // ArduinoOTA.handle(); + + // digitalWrite(led1, HIGH); + + // vTaskDelay(50 / portTICK_PERIOD_MS); + + // // Turn the LED off + // digitalWrite(led1, LOW); + + // vTaskDelay(50 / portTICK_PERIOD_MS); + + // }while(true); + + // hassio + // you don't need to verify return status + // client.begin(mac); + + // set device's details (optional) + device.setName("FlipDotDisplay"); + device.setSoftwareVersion("1.0.0"); + + // set icon (optional) + flipDotDisplay.setIcon("mdi:lightbulb"); + flipDotDisplay.setName("FlipDot Display Clock"); + + // handle switch state + flipDotDisplay.onCommand(onSwitchStateChanged); + + + powerSwitch.setIcon("mdi:lightbulb"); + powerSwitch.setName("FlipDot Display Power"); + powerSwitch.onCommand(onPowerSwitchStateChanged); + + mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASSWORD); + + display.fillScreen(0); + display.display(); + + hour = millis() / 1000 / 60 / 60; + minute = (millis() / 1000 / 60) % 60; + second = (millis() / 1000 % 60); +} + +void toggleLED(void *parameter) +{ + for (;;) + { // infinite loop + if((power == false) || (!(hour >= 7 && hour < 22))) + { + // OFF + digitalWrite(led1, LOW); + vTaskDelay(1000 / portTICK_PERIOD_MS); + continue; + } + + // Turn the LED on + digitalWrite(led1, HIGH); + + if (wifiStatus) + { + // Pause the task for 500ms + vTaskDelay(500 / portTICK_PERIOD_MS); + } + else + vTaskDelay(100 / portTICK_PERIOD_MS); + + // Turn the LED off + digitalWrite(led1, LOW); + + if (wifiStatus) + { + // Pause the task again for 500ms + vTaskDelay(500 / portTICK_PERIOD_MS); + } + else + vTaskDelay(100 / portTICK_PERIOD_MS); + } +} + +#define DEG2RAD 0.0174532925 +void drawRotatedBitmap(int16_t x, int16_t y, const uint8_t *bitmap, int16_t w, int16_t h, bool color, uint16_t angle) +{ + int16_t newx, newy; + uint8_t data = 0; + + float cosa = cos(angle * DEG2RAD), sina = sin(angle * DEG2RAD); + + x = x - ((w * cosa / 2) - (h * sina / 2)); + y = y - ((h * cosa / 2) + (w * sina / 2)); + + for (int16_t j = 0; j < h; j++) + { + for (int16_t i = 0; i < w; i++) + { + if ((j * w + i) & 7) + data <<= 1; + else + data = pgm_read_byte(bitmap++); + + newx = 0.5 + x + ((i * cosa) - (j * sina)); + newy = 0.5 + y + ((j * cosa) + (i * sina)); + + if (data & 0x80) + display.drawPixel(newx, newy, color); + //else display.drawPixel(newx, newy, 0); + } + } +} + +void turnPowerOn(bool overide = false) +{ + if(power == true || overide == true) + { + // ON + digitalWrite(relay, LOW); + delay(3000); + } +} + +void loop() +{ + ArduinoOTA.handle(); + mqtt.loop(); + updatePower(); + + wifiStatus = WiFi.waitForConnectResult() == WL_CONNECTED; + + if(next_display < millis()) + { + if (hassioDisplayStatus) + // if (false) + { + char t[20]; + char timeStringBuff[50]; //50 chars should be enough + + struct tm timeinfo; + if (!getLocalTime(&timeinfo)) + { + // printFromStart("Failed to get time"); + // return; + + // sprintf(t, "%02d:%02d:%02d\0", millis() / 1000 / 60 / 60, (millis() / 1000 / 60) % 60, (millis() / 1000 % 60)); + // sprintf(t, "%02d%02d%02d\0", millis() / 1000 / 60 / 60, (millis() / 1000 / 60) % 60, (millis() / 1000 % 60)); + } + else + { + // const char *format = "%H:%M:%S"; + const char *format = "%H:%M"; + + // strftime(timeStringBuff, sizeof(timeStringBuff), "%A, %B %d %Y %H:%M:%S", &timeinfo); + strftime(timeStringBuff, sizeof(timeStringBuff), format, &timeinfo); + String asString(timeStringBuff); + + hour = timeinfo.tm_hour; + minute = timeinfo.tm_min; + second = timeinfo.tm_sec; + } + + // display.setFont(&FreeSerif12pt7b); + + // if ((millis() / 1000 % 60) <= 30) + // display.setFont(&FreeSerif12pt7b); + // else + // display.setFont(&FreeSerifItalic12pt7b); + + if(hour <= 7 || (hour >= 23 && minute > 30)) + { + display.fillScreen(0); + display.display(); + + turnPowerOff(); + } + // show time between 7 and 23 with updates every minute, and after that; every 15 minutes. + else if((hour >= 7 && hour < 22) || (minute % 15 == 0)) + // else if((hour >= 7 && hour < 19) || (minute % 5 == 0)) + // else if(true) + { + turnPowerOn(); + + int xoffset = 3; + + display.fillScreen(0); + // display.setTextSize(2); + display.setTextSize(1); + // display.setCursor(7, 3); + display.setCursor(xoffset + 0, 19 - 2); + display.setTextWrap(false); + // display.print(t); + + // if(minute % 2 == 0) + if(hour >= 17 || hour <= 6) + display.drawBitmap(xoffset + 0, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1); + else + display.drawBitmap(xoffset + 0, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1); + + display.drawBitmap(xoffset + 35, 0, clock_thought_bubble_bitmap_clock_thought_bubble, 46, 19, 1); + + display.setCursor(xoffset + 43, (19/2) - 5); + display.print(timeStringBuff); + + display.display(); + } + else + { + turnPowerOff(); + } + } + else + { + display.fillScreen(0); + display.drawBitmap(0, 0, nintendo_bitmap_Nintendo_new, 84, 19, 1); + + // display.drawBitmap(0, 0, nintendo_bitmap_panda_inverted, 17, 18, 1); + // display.drawBitmap(18, 0, nintendo_bitmap_panda_inverted, 17, 18, 1); + // display.drawBitmap(18*2, 0, nintendo_bitmap_panda_inverted, 17, 18, 1); + // display.drawBitmap(18*3, 0, nintendo_bitmap_panda_inverted, 17, 18, 1); + // display.drawBitmap(18*4, 0, nintendo_bitmap_panda_inverted, 17, 18, 1); + + // drawRotatedBitmap(0, 0, nintendo_bitmap_Nintendo_new, 17, 18, 1, 90); + + // Text inside border. + // display.drawBitmap(0, 0, nintendo_bitmap_nintendo_border, 84, 19, 1); + // display.setFont(); + // display.setTextSize(1); + // display.setTextWrap(false); + // display.setCursor(5, 4); + // display.print("Nintendo"); + + // display.setFont(); + // // display.setTextSize(2); + // display.setTextSize(1); + // display.setCursor(0, 0); + // display.setTextWrap(true); + // // display.print(t); + // display.print("Carla & Jan Kragt"); + + display.display(); + + turnPowerOff(); + } + + next_display += 1000; + // next_display += 500; + } +} \ No newline at end of file diff --git a/src/esp32/secrets.example.h b/src/esp32/secrets.example.h new file mode 100644 index 0000000..b432bdd --- /dev/null +++ b/src/esp32/secrets.example.h @@ -0,0 +1,11 @@ +// Copy this file to secrets.h and fill in your own values. +// secrets.h is gitignored so credentials never end up in the repository. +#pragma once + +#define WIFI_SSID "your-wifi-ssid" +#define WIFI_PASSWORD "your-wifi-password" + +// Home Assistant MQTT broker +#define BROKER_ADDR IPAddress(192, 168, 1, 10) +#define MQTT_USER "mqtt-user" +#define MQTT_PASSWORD "mqtt-password" diff --git a/src/nano/README.md b/src/nano/README.md new file mode 100644 index 0000000..d0a0178 --- /dev/null +++ b/src/nano/README.md @@ -0,0 +1,57 @@ +# Arduino Nano: flipdot driver + +PlatformIO environment: `nano` + +The Nano is connected directly to the Brose panel electronics. It keeps an 84 × 19 frame +buffer and takes drawing commands over serial (115200 baud). It has no clock or network +logic. + +## Behaviour + +- **Boot:** the Nano flips the whole display on and then off, shows the splash bitmap and + prints the time a full redraw took. +- **`display()`:** compares the new buffer with the last one that was shown and strobes + only the dots that changed. +- **Every 2 minutes** (`REFRESH_TIME`) it forces a full redraw to fix dots that got stuck. +- **Flip pulse length** (µs) is set with the `T` command and stored in EEPROM, so it is + kept after a reboot. + +## Serial protocol + +Each command is one ASCII line. Coordinates are 2-digit **hex**. Color is `0` (black) or +`1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D` and `T`. + +| Cmd | Format | Meaning | +|-----|--------|---------| +| `s` | `sXXYYc` | Set pixel in the buffer | +| `S` | `SXXYYc` | Set pixel and flip it right away (direct mode) | +| `F` | `Fc` | Fill the whole buffer | +| `D` | `D` | Push the buffer to the display (changed dots only) | +| `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. `\n` in TEXT starts a new line. | +| `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels. | +| `R` | `R` | Print the buffer as ASCII art on the serial port | +| `T` | `Tnnn` | Set the flip pulse length in µs (decimal), stored in EEPROM | + +Example (what the ESP32 sends for one pixel and a refresh): + +``` +s0a051 +D +``` + +## Wiring (default pinout) + +The pins are defined in [lib/BROSE9323/BROSE9323.h](../../lib/BROSE9323/BROSE9323.h). +If you use the PLCC adapter board, build with `-D FLIPDOT_PLCC_ADAPTER` to switch to its +pinout. + +| Signal | Nano pin | +|--------|----------| +| ENABLE | D7 | +| ADDR_0 / 1 / 2 (panel select) | D3 / D5 / D6 | +| COL_DATA | D4 | +| COL_0 … COL_4 | D8, D10, D9, D11, D12 | +| ROW_0 … ROW_4 | A3, A4, A2, A0, A1 | +| ROW_RESET | D13 | +| ROW_SET | A5 | +| Serial RX | D0 (from ESP32 TX) | diff --git a/src/nano/main.cpp b/src/nano/main.cpp new file mode 100644 index 0000000..5d9d99b --- /dev/null +++ b/src/nano/main.cpp @@ -0,0 +1,233 @@ +#include +#include "BROSE9323.h" +// fixes +#include +#include + +#include + +BROSE9323 display(84, 19, 28, 200); + +uint32_t refresh_at; +const uint32_t REFRESH_TIME = 120000L; // 600000L // 10 min + +uint16_t _flip_time = 200; +unsigned int address = 0; +unsigned int address2 = 1; + +//#define DEBUG +void setup() +{ + Serial.begin(115200); + Serial.println("Hello World!"); + + uint16_t read1 = EEPROM.read(address); + uint16_t read2 = EEPROM.read(address2); + + if(read1 == read2) + _flip_time = read1; + + display.setTiming(_flip_time); + + display.begin(); + delay(1000); + + display.fillScreen(1); + display.display(); + display.fillScreen(0); + uint32_t t1 = millis(); + display.display(); + uint32_t t2 = millis(); + Serial.println(t2 - t1); +} + +uint8_t hex2dec(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return 0; +} + +void loop() +{ + if (Serial.available()) + { + char cmd = Serial.read(); + switch (cmd) + { + // Ignore CR + case 0x0A: + break; + // Draw Bitmap (must be n x 8 wide) + case 'b': + case 'B': //BxxyywwhhcBITMAP (xx/ww = 8 bit hex x position/width; yy/hh = 8 bit y position/height; c = 0/1 color; BITMAP = bytes as hex) + { + // May lead to buffer overflow, you may need to segment the bitmap into multiple frames + // Or just use the ser pixel function + while (Serial.available() < 9) + { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t w = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + if (w % 4) + { + Serial.println("ERR: Invalid bitmap width (not dividable by 4)"); + Serial.flush(); + break; + } + uint8_t h = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + if (h == 0) + { + Serial.println("ERR: Invalid bitmap height (0)"); + Serial.flush(); + break; + } + uint8_t c = Serial.read() - '0'; + uint8_t b = 0; + for (uint8_t _y = 0; _y < h; _y++) + { + for (uint8_t _x = 0; _x < w; _x++) + { + if ((_x & 0x3) == 0) + { + if (!Serial.available()) + { + Serial.println("WARN: OUTOFMEM"); + } + while (!Serial.available()) + { + delay(1); + } + b = hex2dec(Serial.read()); + } + if (b & (1 << (0x3 - (_x & 0x3)))) + { + display.drawPixel(x + _x, y + _y, c); + } + } + } + break; + } + // Display + case 'd': + case 'D': //D + { + display.display(); + break; + } + // Fill Screen + case 'f': + case 'F': //Fc (c = 0/1 color) + { + while (Serial.available() <= 2) + { + delay(1); + } + display.fillScreen(Serial.read() - '0'); + break; + } + // Print Text + case 'p': + case 'P': // PxxyycsTEXT (xx = 8 bit hex x position; yy = 8 bit y position; c = 0/1 color; s = 1/2 size; TEXT = string, newline = escaped \\n) + { + delay(5); + while (Serial.available() <= 6) + { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t c = Serial.read() - '0'; + display.setCursor(x, y); + display.setTextColor(c, !c); + display.setTextSize(Serial.read() - '0'); + while (true) + { + if (!Serial.available()) + { + delay(1); + } + if (Serial.peek() == '\n') + break; + if (Serial.peek() == '\r') + { + Serial.read(); + continue; + }; + char c = Serial.read(); + if (c == '\\' && Serial.peek() == 'n') + { + display.println(); + Serial.read(); + } + else + { + display.write(c); + } + } + return; + } + // Return buffer (show pixels on console) + case 'r': + case 'R': //R + { + display.printBuffer(); + Serial.flush(); + break; + } + // Set Pixel + case 'S': + display.setDirect(true); + case 's': // Sxxyyc (xx = 8 bit hex x position; y = 8 bit y position; c = 0/1 color) + { + while (Serial.available() <= 6) + { + delay(1); + } + uint8_t x = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + uint8_t y = (hex2dec(Serial.read()) << 4) | hex2dec(Serial.read()); + bool color = hex2dec(Serial.read()); + display.drawPixel(x, y, color); + display.setDirect(false); + break; + } + // Set Timing + case 'T': + case 't': // Tttt (ttt = decimal timing in us) + { + while (Serial.available() <= 6) + { + delay(1); + } + uint16_t t = atoi(Serial.readStringUntil('\n').c_str()); + + EEPROM.update(address, t); + EEPROM.update(address2, t); + + display.setTiming(t); + break; + } + default: +#ifdef DEBUG + Serial.print("0x"); + Serial.print(cmd >> 4, HEX); + Serial.print(cmd & 3, HEX); + Serial.println(" - command not known."); + Serial.flush(); +#endif + break; + } + } + // Refresh content every REFRESH_TIME (by default 2 min) + if (refresh_at < millis()) + { + refresh_at = millis() + REFRESH_TIME; + display.display(true); + } +}