diff --git a/.gitignore b/.gitignore index 71242a5..c246de7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ $RECYCLE.BIN/ # Editors *.swp *~ + +# Python environment for the ESPHome CLI +.venv/ + +# ESPHome build output and local secrets +.esphome/ +esphome/secrets.yaml + +# Local test copy of el512-panel.yaml that uses ../components instead of git +esphome/*-local.yaml + +# Python bytecode (ESPHome component) +__pycache__/ diff --git a/components/el512/__init__.py b/components/el512/__init__.py new file mode 100644 index 0000000..caf9fee --- /dev/null +++ b/components/el512/__init__.py @@ -0,0 +1,10 @@ +"""Planar EL512.256-H electroluminescent panel (512x256) on an ESP32. + +Driven by I2S0 in parallel mode with DMA; see el512_panel.h for the details. +""" + +import esphome.codegen as cg + +CODEOWNERS = ["@mangavalk"] + +el512_ns = cg.esphome_ns.namespace("el512") diff --git a/components/el512/display.py b/components/el512/display.py new file mode 100644 index 0000000..545e6df --- /dev/null +++ b/components/el512/display.py @@ -0,0 +1,66 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA, CONF_PAGES + +from . import el512_ns + +DEPENDENCIES = ["esp32"] + +CONF_VCLK_PIN = "vclk_pin" +CONF_VID_PIN = "vid_pin" +CONF_TVID_PIN = "tvid_pin" +CONF_HS_PIN = "hs_pin" +CONF_VS_PIN = "vs_pin" +CONF_ENABLE_PIN = "enable_pin" +CONF_LOW_POWER_PIN = "low_power_pin" +CONF_STILL_ALIVE_PAGE = "still_alive_page" + +EL512Display = el512_ns.class_("EL512Display", display.DisplayBuffer) + +# The signals are routed through the GPIO matrix, so any output-capable GPIO +# works; the defaults are the wiring in include/pins.h. +CONFIG_SCHEMA = cv.All( + display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(EL512Display), + cv.Optional(CONF_VCLK_PIN, default=18): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_VID_PIN, default=19): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_TVID_PIN, default=21): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_HS_PIN, default=22): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_VS_PIN, default=23): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_ENABLE_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_LOW_POWER_PIN): pins.internal_gpio_output_pin_number, + # Page (from `pages:`) that plays the Portal end-credits show; its + # lambda should call it.draw_still_alive() via id(...). + cv.Optional(CONF_STILL_ALIVE_PAGE): cv.use_id(display.DisplayPage), + } + ).extend(cv.polling_component_schema("1s")), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await display.register_display(var, config) + + cg.add( + var.set_pins( + config[CONF_VCLK_PIN], + config[CONF_VID_PIN], + config[CONF_TVID_PIN], + config[CONF_HS_PIN], + config[CONF_VS_PIN], + config.get(CONF_ENABLE_PIN, -1), + config.get(CONF_LOW_POWER_PIN, -1), + ) + ) + if CONF_STILL_ALIVE_PAGE in config: + page = await cg.get_variable(config[CONF_STILL_ALIVE_PAGE]) + cg.add(var.set_still_alive_page(page)) + if CONF_LAMBDA in config: + lambda_ = await cg.process_lambda( + config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/components/el512/el512_display.cpp b/components/el512/el512_display.cpp new file mode 100644 index 0000000..3b30aa5 --- /dev/null +++ b/components/el512/el512_display.cpp @@ -0,0 +1,110 @@ +#include "el512_display.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::el512 { + +static const char *const TAG = "el512"; + +void EL512Display::setup() { + this->panel_ = new EL512Panel(this->pins_); // NOLINT(cppcoreguidelines-owning-memory) + if (!this->panel_->begin()) { + ESP_LOGE(TAG, "Panel init failed (out of DMA memory or no free interrupt)"); + this->mark_failed(); + return; + } + // Apply states that switches may have restored before setup ran. + this->panel_->setLowPower(this->low_power_); + this->panel_->setEnabled(this->enabled_); +} + +void EL512Display::loop() { + if (this->still_alive_page_ == nullptr || this->panel_ == nullptr || !this->panel_->running()) + return; + const bool active = this->is_still_alive_playing(); + const uint32_t now = millis(); + if (active && !this->still_alive_was_active_) { + // Remember where we came from, then start the show from the top. + this->still_alive_return_ = this->previous_page_ != this->still_alive_page_ ? this->previous_page_ : nullptr; + this->still_alive_.start(now); + ESP_LOGI(TAG, "Still Alive started; start the song in %u ms", (unsigned) this->still_alive_song_start_ms()); + } + this->still_alive_was_active_ = active; + if (!active) + return; + if (this->still_alive_.finished(now) && this->still_alive_return_ != nullptr) { + this->show_page(this->still_alive_return_); + this->update(); + return; + } + if (this->still_alive_.update(now)) + this->update(); +} + +void EL512Display::play_still_alive() { + if (this->still_alive_page_ == nullptr) + return; + if (this->is_still_alive_playing()) { + this->still_alive_.start(millis()); // already showing: just restart + } else { + this->show_page(this->still_alive_page_); // loop() starts it + } +} + +void EL512Display::draw_still_alive() { + if (this->panel_ != nullptr) + this->still_alive_.draw(*this->panel_); +} + +void EL512Display::update() { + if (this->panel_ == nullptr || !this->panel_->running()) + return; + this->do_update_(); + this->panel_->show(); +} + +void EL512Display::fill(Color color) { + // The panel clear is a memset; with clipping active, fall back to per-pixel. + if (this->panel_ == nullptr || this->get_clipping().is_set()) { + Display::fill(color); + return; + } + this->panel_->clear(color.is_on()); +} + +void EL512Display::draw_absolute_pixel_internal(int x, int y, Color color) { + if (this->panel_ != nullptr) + this->panel_->setPixel(x, y, color.is_on()); +} + +void EL512Display::set_enabled(bool on) { + this->enabled_ = on; + if (this->panel_ != nullptr) + this->panel_->setEnabled(on); +} + +void EL512Display::set_low_power(bool on) { + this->low_power_ = on; + if (this->panel_ != nullptr) + this->panel_->setLowPower(on); +} + +void EL512Display::dump_config() { + LOG_DISPLAY("", "EL512.256-H", this); + ESP_LOGCONFIG(TAG, + " Pins: VCLK %d, VID %d, TVID %d, HS %d, VS %d, ENABLE %d, _LOWPWR %d\n" + " Refresh: %.1f Hz, DMA ring %u bytes\n" + " Free internal heap: %u bytes", + this->pins_.vclk, this->pins_.vid, this->pins_.tvid, this->pins_.hs, this->pins_.vs, + this->pins_.enable, this->pins_.lowpwr_n, EL512Panel::refreshHz(), + (unsigned) EL512Panel::RING_BYTES, + (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) + ESP_LOGE(TAG, " Init failed"); +} + +} // namespace esphome::el512 diff --git a/components/el512/el512_display.h b/components/el512/el512_display.h new file mode 100644 index 0000000..132c64e --- /dev/null +++ b/components/el512/el512_display.h @@ -0,0 +1,68 @@ +#pragma once + +#include "el512_helpers.h" +#include "el512_panel.h" +#include "esphome/components/display/display_buffer.h" +#include "esphome/core/component.h" +#include "still_alive.h" + +namespace esphome::el512 { + +// ESPHome display for the Planar EL512.256-H. Drawing goes into the panel +// driver's back buffer; every update() hands it to the refresh in one piece. +class EL512Display : public display::DisplayBuffer { + public: + void set_pins(int vclk, int vid, int tvid, int hs, int vs, int enable, int low_power) { + this->pins_ = {(int8_t) vclk, (int8_t) vid, (int8_t) tvid, (int8_t) hs, + (int8_t) vs, (int8_t) enable, (int8_t) low_power}; + } + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + void fill(Color color) override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; } + + // Display on/off (ENABLE) and low-power mode (_LOWPWR), e.g. from template switches. + void set_enabled(bool on); + void set_low_power(bool on); + bool is_enabled() const { return this->enabled_; } + bool is_low_power() const { return this->low_power_; } + bool has_enable_pin() const { return this->pins_.enable >= 0; } + bool has_low_power_pin() const { return this->pins_.lowpwr_n >= 0; } + + // Portal end-credits show. Showing this page restarts it; while it is the + // active page the display redraws as the show advances, and when it ends the + // previous page comes back. Draw it from that page's lambda with + // draw_still_alive(). + void set_still_alive_page(display::DisplayPage *page) { this->still_alive_page_ = page; } + void draw_still_alive(); + // Show the Still Alive page and start the show from the top (also when it is + // already playing). + void play_still_alive(); + bool is_still_alive_playing() const { + return this->still_alive_page_ != nullptr && this->get_active_page() == this->still_alive_page_; + } + // When to start the music, in ms after the page was shown. + uint32_t still_alive_song_start_ms() const { return el512sa::StillAlivePlayer::songStartMs(); } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override; + int get_width_internal() override { return EL512Panel::WIDTH; } + int get_height_internal() override { return EL512Panel::HEIGHT; } + + EL512Pins pins_{18, 19, 21, 22, 23, -1, -1}; + EL512Panel *panel_{nullptr}; + bool enabled_{true}; + bool low_power_{false}; + + display::DisplayPage *still_alive_page_{nullptr}; + display::DisplayPage *still_alive_return_{nullptr}; + bool still_alive_was_active_{false}; + el512sa::StillAlivePlayer still_alive_; +}; + +} // namespace esphome::el512 diff --git a/components/el512/el512_font.h b/components/el512/el512_font.h new file mode 100644 index 0000000..1dfdf1e --- /dev/null +++ b/components/el512/el512_font.h @@ -0,0 +1,134 @@ +// Classic 5x7 console font (printable ASCII 32..126), drawn in a 6x8 cell. +// Column-major, 5 bytes per character, bit 0 = top row. +// +// Taken from glcdfont.c of the Adafruit GFX Library +// (https://github.com/adafruit/Adafruit-GFX-Library), license below. +// +// Software License Agreement (BSD License) +// +// Copyright (c) 2012 Adafruit Industries. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// - Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// - Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +#pragma once + +#include + +static constexpr char EL512_FONT_FIRST = 32; +static constexpr char EL512_FONT_LAST = 126; + +static const uint8_t EL512_FONT[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, // space + 0x00, 0x00, 0x5F, 0x00, 0x00, // ! + 0x00, 0x07, 0x00, 0x07, 0x00, // " + 0x14, 0x7F, 0x14, 0x7F, 0x14, // # + 0x24, 0x2A, 0x7F, 0x2A, 0x12, // $ + 0x23, 0x13, 0x08, 0x64, 0x62, // % + 0x36, 0x49, 0x56, 0x20, 0x50, // & + 0x00, 0x08, 0x07, 0x03, 0x00, // ' + 0x00, 0x1C, 0x22, 0x41, 0x00, // ( + 0x00, 0x41, 0x22, 0x1C, 0x00, // ) + 0x2A, 0x1C, 0x7F, 0x1C, 0x2A, // * + 0x08, 0x08, 0x3E, 0x08, 0x08, // + + 0x00, 0x80, 0x70, 0x30, 0x00, // , + 0x08, 0x08, 0x08, 0x08, 0x08, // - + 0x00, 0x00, 0x60, 0x60, 0x00, // . + 0x20, 0x10, 0x08, 0x04, 0x02, // / + 0x3E, 0x51, 0x49, 0x45, 0x3E, // 0 + 0x00, 0x42, 0x7F, 0x40, 0x00, // 1 + 0x72, 0x49, 0x49, 0x49, 0x46, // 2 + 0x21, 0x41, 0x49, 0x4D, 0x33, // 3 + 0x18, 0x14, 0x12, 0x7F, 0x10, // 4 + 0x27, 0x45, 0x45, 0x45, 0x39, // 5 + 0x3C, 0x4A, 0x49, 0x49, 0x31, // 6 + 0x41, 0x21, 0x11, 0x09, 0x07, // 7 + 0x36, 0x49, 0x49, 0x49, 0x36, // 8 + 0x46, 0x49, 0x49, 0x29, 0x1E, // 9 + 0x00, 0x00, 0x14, 0x00, 0x00, // : + 0x00, 0x40, 0x34, 0x00, 0x00, // ; + 0x00, 0x08, 0x14, 0x22, 0x41, // < + 0x14, 0x14, 0x14, 0x14, 0x14, // = + 0x00, 0x41, 0x22, 0x14, 0x08, // > + 0x02, 0x01, 0x59, 0x09, 0x06, // ? + 0x3E, 0x41, 0x5D, 0x59, 0x4E, // @ + 0x7C, 0x12, 0x11, 0x12, 0x7C, // A + 0x7F, 0x49, 0x49, 0x49, 0x36, // B + 0x3E, 0x41, 0x41, 0x41, 0x22, // C + 0x7F, 0x41, 0x41, 0x41, 0x3E, // D + 0x7F, 0x49, 0x49, 0x49, 0x41, // E + 0x7F, 0x09, 0x09, 0x09, 0x01, // F + 0x3E, 0x41, 0x41, 0x51, 0x73, // G + 0x7F, 0x08, 0x08, 0x08, 0x7F, // H + 0x00, 0x41, 0x7F, 0x41, 0x00, // I + 0x20, 0x40, 0x41, 0x3F, 0x01, // J + 0x7F, 0x08, 0x14, 0x22, 0x41, // K + 0x7F, 0x40, 0x40, 0x40, 0x40, // L + 0x7F, 0x02, 0x1C, 0x02, 0x7F, // M + 0x7F, 0x04, 0x08, 0x10, 0x7F, // N + 0x3E, 0x41, 0x41, 0x41, 0x3E, // O + 0x7F, 0x09, 0x09, 0x09, 0x06, // P + 0x3E, 0x41, 0x51, 0x21, 0x5E, // Q + 0x7F, 0x09, 0x19, 0x29, 0x46, // R + 0x26, 0x49, 0x49, 0x49, 0x32, // S + 0x03, 0x01, 0x7F, 0x01, 0x03, // T + 0x3F, 0x40, 0x40, 0x40, 0x3F, // U + 0x1F, 0x20, 0x40, 0x20, 0x1F, // V + 0x3F, 0x40, 0x38, 0x40, 0x3F, // W + 0x63, 0x14, 0x08, 0x14, 0x63, // X + 0x03, 0x04, 0x78, 0x04, 0x03, // Y + 0x61, 0x59, 0x49, 0x4D, 0x43, // Z + 0x00, 0x7F, 0x41, 0x41, 0x41, // [ + 0x02, 0x04, 0x08, 0x10, 0x20, // backslash + 0x00, 0x41, 0x41, 0x41, 0x7F, // ] + 0x04, 0x02, 0x01, 0x02, 0x04, // ^ + 0x40, 0x40, 0x40, 0x40, 0x40, // _ + 0x00, 0x03, 0x07, 0x08, 0x00, // ` + 0x20, 0x54, 0x54, 0x78, 0x40, // a + 0x7F, 0x28, 0x44, 0x44, 0x38, // b + 0x38, 0x44, 0x44, 0x44, 0x28, // c + 0x38, 0x44, 0x44, 0x28, 0x7F, // d + 0x38, 0x54, 0x54, 0x54, 0x18, // e + 0x00, 0x08, 0x7E, 0x09, 0x02, // f + 0x18, 0xA4, 0xA4, 0x9C, 0x78, // g + 0x7F, 0x08, 0x04, 0x04, 0x78, // h + 0x00, 0x44, 0x7D, 0x40, 0x00, // i + 0x20, 0x40, 0x40, 0x3D, 0x00, // j + 0x7F, 0x10, 0x28, 0x44, 0x00, // k + 0x00, 0x41, 0x7F, 0x40, 0x00, // l + 0x7C, 0x04, 0x78, 0x04, 0x78, // m + 0x7C, 0x08, 0x04, 0x04, 0x78, // n + 0x38, 0x44, 0x44, 0x44, 0x38, // o + 0xFC, 0x18, 0x24, 0x24, 0x18, // p + 0x18, 0x24, 0x24, 0x18, 0xFC, // q + 0x7C, 0x08, 0x04, 0x04, 0x08, // r + 0x48, 0x54, 0x54, 0x54, 0x24, // s + 0x04, 0x04, 0x3F, 0x44, 0x24, // t + 0x3C, 0x40, 0x40, 0x20, 0x7C, // u + 0x1C, 0x20, 0x40, 0x20, 0x1C, // v + 0x3C, 0x40, 0x30, 0x40, 0x3C, // w + 0x44, 0x28, 0x10, 0x28, 0x44, // x + 0x4C, 0x90, 0x90, 0x90, 0x7C, // y + 0x44, 0x64, 0x54, 0x4C, 0x44, // z + 0x00, 0x08, 0x36, 0x41, 0x00, // { + 0x00, 0x00, 0x77, 0x00, 0x00, // | + 0x00, 0x41, 0x36, 0x08, 0x00, // } + 0x02, 0x01, 0x02, 0x04, 0x02, // ~ +}; diff --git a/components/el512/el512_helpers.h b/components/el512/el512_helpers.h new file mode 100644 index 0000000..dff352b --- /dev/null +++ b/components/el512/el512_helpers.h @@ -0,0 +1,108 @@ +// Small helpers for page lambdas (available as el512::... in YAML lambdas). +#pragma once + +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::el512 { + +// Split the "a|b|c;d|e|f" lists made by the Home Assistant package. +inline std::vector split(const std::string &s, char sep) { + std::vector out; + if (s.empty()) + return out; + size_t start = 0; + while (true) { + const size_t end = s.find(sep, start); + out.push_back(s.substr(start, end == std::string::npos ? std::string::npos : end - start)); + if (end == std::string::npos) + return out; + start = end + 1; + } +} + +// Field i of a "|"-separated entry, or "" if missing. +inline std::string field(const std::string &entry, size_t i) { + const auto parts = split(entry, '|'); + return i < parts.size() ? parts[i] : std::string(); +} + +// Material Design Icons glyph (UTF-8) for a Home Assistant weather condition. +inline const char *weather_icon(const std::string &c) { + if (c == "clear-night") return "\U000F0594"; + if (c == "cloudy") return "\U000F0590"; + if (c == "fog") return "\U000F0591"; + if (c == "hail") return "\U000F0592"; + if (c == "lightning") return "\U000F0593"; + if (c == "lightning-rainy") return "\U000F067E"; + if (c == "partlycloudy") return "\U000F0595"; + if (c == "pouring") return "\U000F0596"; + if (c == "rainy") return "\U000F0597"; + if (c == "snowy") return "\U000F0598"; + if (c == "snowy-rainy") return "\U000F067F"; + if (c == "sunny") return "\U000F0599"; + if (c == "windy") return "\U000F059D"; + if (c == "windy-variant") return "\U000F059E"; + return "\U000F05D6"; // exceptional / unknown +} + +// Readable weather condition ("partlycloudy" -> "Partly cloudy"). +inline std::string weather_text(const std::string &c) { + if (c == "clear-night") return "Clear"; + if (c == "partlycloudy") return "Partly cloudy"; + if (c == "lightning-rainy") return "Thunderstorm"; + if (c == "snowy-rainy") return "Sleet"; + if (c == "windy-variant") return "Windy"; + if (c == "exceptional") return "Exceptional"; + if (c.empty()) return ""; + std::string s = c; + s[0] = (char) toupper(s[0]); + return s; +} + +// Cut text with "..." so it fits in max_width pixels in the given font. +inline std::string fit(display::BaseFont *font, const std::string &text, int max_width) { + int w, x_off, baseline, h; + font->measure(text.c_str(), &w, &x_off, &baseline, &h); + if (w <= max_width) + return text; + std::string s = text; + while (!s.empty()) { + // Drop a whole UTF-8 character. + do { + s.pop_back(); + } while (!s.empty() && (s.back() & 0xC0) == 0x80); + const std::string t = s + "..."; + font->measure(t.c_str(), &w, &x_off, &baseline, &h); + if (w <= max_width) + return t; + } + return "..."; +} + +// 245 -> "4:05", 3725 -> "1:02:05". +inline std::string mmss(int seconds) { + if (seconds < 0) + seconds = 0; + char buf[16]; + if (seconds >= 3600) + snprintf(buf, sizeof(buf), "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60); + else + snprintf(buf, sizeof(buf), "%d:%02d", seconds / 60, seconds % 60); + return buf; +} + +// 1250 -> "1250 W", 4200 -> "4.20 kW". +inline std::string watts(float w) { + char buf[16]; + if (w >= 1000 || w <= -1000) + snprintf(buf, sizeof(buf), "%.2f kW", w / 1000.0f); + else + snprintf(buf, sizeof(buf), "%.0f W", w); + return buf; +} + +} // namespace esphome::el512 diff --git a/components/el512/el512_panel.cpp b/components/el512/el512_panel.cpp new file mode 100644 index 0000000..848a696 --- /dev/null +++ b/components/el512/el512_panel.cpp @@ -0,0 +1,335 @@ +#include "el512_panel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() +#include +#else +#include +#endif +#if __has_include() +#include +#else +#include +#endif + +namespace { + +// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock. +constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000; +constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK; +constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ; +static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0, + "byte rate must divide 160 MHz exactly"); +static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range"); + +// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib). +constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX; + +// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each +// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the +// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified +// on the panel). +constexpr bool INVERT_VCLK = true; + +static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long"); +static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words"); +static_assert(EL512Panel::RING_LINES < EL512Panel::LINES_PER_FRAME, "ring longer than a frame"); + +constexpr uint16_t FB_ROW_BYTES = EL512Panel::WIDTH / 8; +constexpr uint16_t DATA_BYTES = EL512Panel::DATA_CLOCKS_PER_ROW * EL512Panel::BYTES_PER_CLOCK; + +// One framebuffer byte (8 pixels = 4 clocks) -> 8 stream bytes as two DMA words, +// already in I2S byte order. Built once in begin(); read from the ISR. +DRAM_ATTR uint32_t s_lut[256][2]; + +void buildLut() { + for (int b = 0; b < 256; b++) { + uint8_t bytes[8]; + for (int c = 0; c < 4; c++) { + // Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so + // x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID). + const uint8_t bits = (uint8_t)(b << (c * 2)); + uint8_t v = EL512Panel::BIT_HS; + if (bits & 0x80) v |= EL512Panel::BIT_TVID; + if (bits & 0x40) v |= EL512Panel::BIT_VID; + bytes[(2 * c) ^ 2] = v; + bytes[(2 * c + 1) ^ 2] = v; + } + memcpy(s_lut[b], bytes, sizeof(bytes)); + } +} + +inline uint32_t IRAM_ATTR repeat4(uint8_t v) { return v * 0x01010101u; } + +void resetI2S0() { + const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M; + I2S0.lc_conf.val |= lcReset; + I2S0.lc_conf.val &= ~lcReset; + const uint32_t confReset = + I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M; + I2S0.conf.val |= confReset; + I2S0.conf.val &= ~confReset; +} + +void routeOutput(int8_t pin, uint32_t signal, bool invert) { + if (pin < 0) return; + esp_rom_gpio_pad_select_gpio(pin); + gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT); + esp_rom_gpio_connect_out_signal(pin, signal, invert, false); +} + +void setupGpioOutput(int8_t pin, bool level) { + if (pin < 0) return; + esp_rom_gpio_pad_select_gpio(pin); + gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT); + gpio_set_level((gpio_num_t)pin, level); +} + +} // namespace + +EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {} + +EL512Panel::~EL512Panel() { end(); } + +bool EL512Panel::begin() { + if (_running) return true; + + setupGpioOutput(_pins.enable, false); + setupGpioOutput(_pins.lowpwr_n, true); + _enabled = false; + _lowPower = false; + + buildLut(); + // Both framebuffers are read from the ISR, so they must be internal RAM. + _fb = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + _front = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + _ring = (uint8_t*)heap_caps_malloc(RING_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + if (!_fb || !_front || !_ring || !startI2S()) { + end(); + return false; + } + _running = true; + setEnabled(true); + return true; +} + +void EL512Panel::end() { + if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, 0); + _enabled = false; + stopI2S(); + _running = false; + heap_caps_free(_fb); + _fb = nullptr; + heap_caps_free(_front); + _front = nullptr; + heap_caps_free(_ring); + _ring = nullptr; +} + +void EL512Panel::setEnabled(bool on) { + _enabled = on; + if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, on); +} + +void EL512Panel::setLowPower(bool on) { + _lowPower = on; + if (_pins.lowpwr_n >= 0) gpio_set_level((gpio_num_t)_pins.lowpwr_n, !on); +} + +void EL512Panel::setPixel(int16_t x, int16_t y, bool on) { + if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return; + uint8_t& b = _fb[(y * WIDTH + x) >> 3]; + const uint8_t mask = 0x80 >> (x & 7); + if (on) + b |= mask; + else + b &= ~mask; +} + +bool EL512Panel::getPixel(int16_t x, int16_t y) const { + if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false; + return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7)); +} + +void EL512Panel::clear(bool on) { + if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES); +} + +void EL512Panel::show() { + if (!_fb || !_front) return; + if (!_running) { + memcpy(_front, _fb, FRAMEBUFFER_BYTES); + return; + } + // The ISR copies _fb to _front just before it encodes line 0 of a frame. + _swapPending = true; + for (int i = 0; i < 40 && _swapPending; i++) vTaskDelay(1); +} + +void IRAM_ATTR EL512Panel::encodeRow(uint16_t line, uint8_t* out) const { + // VS is held high during the last blanking line so that the first HS high + // time ending after its rising edge is row 0 (datasheet: Video Data Input). + const uint32_t vs = repeat4(line == LINES_PER_FRAME - 1 ? BIT_VS : 0); + uint32_t* o = (uint32_t*)out; + + if (line >= HEIGHT) { + for (uint16_t i = 0; i < ROW_BYTES / 4; i++) o[i] = vs; + return; + } + const uint8_t* src = _front + (size_t)line * FB_ROW_BYTES; + for (uint16_t k = 0; k < FB_ROW_BYTES; k++) { + o[2 * k] = s_lut[src[k]][0]; + o[2 * k + 1] = s_lut[src[k]][1]; + } + // Blanking clocks: HS low. Uniform and word-aligned, so no byte swap needed. + for (uint16_t i = DATA_BYTES / 4; i < ROW_BYTES / 4; i++) o[i] = vs; +} + +void IRAM_ATTR EL512Panel::fillChunk(uint16_t chunk) { + uint16_t line = _chunkLine[chunk]; + uint8_t* out = _ring + (size_t)chunk * LINES_PER_CHUNK * ROW_BYTES; + for (uint16_t i = 0; i < LINES_PER_CHUNK; i++) { + if (line == 0 && _swapPending) { + const uint32_t* s = (const uint32_t*)_fb; + uint32_t* d = (uint32_t*)_front; + for (size_t w = 0; w < FRAMEBUFFER_BYTES / 4; w++) d[w] = s[w]; + _swapPending = false; + } + encodeRow(line, out + (size_t)i * ROW_BYTES); + line = (line + 1 == LINES_PER_FRAME) ? 0 : line + 1; + } +} + +void IRAM_ATTR EL512Panel::isr(void* arg) { + auto* self = static_cast(arg); + const uint32_t status = I2S0.int_st.val; + I2S0.int_clr.val = status; + if (!(status & I2S_OUT_EOF_INT_ST_M)) return; + + // The descriptor that just finished plays again RING_LINES lines later. + const lldesc_t* done = (const lldesc_t*)I2S0.out_eof_des_addr; + const uint16_t chunk = done - (const lldesc_t*)self->_desc; + if (chunk >= CHUNK_COUNT) return; + uint16_t next = self->_chunkLine[chunk] + RING_LINES; + if (next >= LINES_PER_FRAME) next -= LINES_PER_FRAME; + self->_chunkLine[chunk] = next; + self->fillChunk(chunk); +} + +bool EL512Panel::startI2S() { + _desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA); + if (!_desc) return false; + for (uint16_t i = 0; i < CHUNK_COUNT; i++) { + _chunkLine[i] = i * LINES_PER_CHUNK; + fillChunk(i); + lldesc_t& d = _desc[i]; + d.size = LINES_PER_CHUNK * ROW_BYTES; + d.length = d.size; + d.owner = 1; + d.eof = 1; // EOF interrupt after every chunk + d.buf = _ring + (size_t)i * LINES_PER_CHUNK * ROW_BYTES; + d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT]; + } + + periph_module_enable(PERIPH_I2S0_MODULE); + resetI2S0(); + + // 8-bit parallel LCD mode (bitluni ESP32Lib setup; the i ^ 2 byte order in + // the lookup table depends on these FIFO/channel modes). + I2S0.conf2.val = 0; + I2S0.conf2.lcd_en = 1; + I2S0.conf2.lcd_tx_wrx2_en = 1; + I2S0.conf2.lcd_tx_sdx2_en = 0; + + I2S0.sample_rate_conf.val = 0; + I2S0.sample_rate_conf.tx_bits_mod = 8; + I2S0.sample_rate_conf.tx_bck_div_num = 1; + + // PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK. + I2S0.clkm_conf.val = 0; + I2S0.clkm_conf.clka_en = 0; + I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV; + I2S0.clkm_conf.clkm_div_a = 1; + I2S0.clkm_conf.clkm_div_b = 0; + + I2S0.fifo_conf.val = 0; + I2S0.fifo_conf.tx_fifo_mod_force_en = 1; + I2S0.fifo_conf.tx_fifo_mod = 1; + I2S0.fifo_conf.tx_data_num = 32; + I2S0.fifo_conf.dscr_en = 1; + + I2S0.conf1.val = 0; + I2S0.conf1.tx_stop_en = 0; + I2S0.conf1.tx_pcm_bypass = 1; + + I2S0.conf_chan.val = 0; + I2S0.conf_chan.tx_chan_mod = 1; + + I2S0.conf.tx_right_first = 1; + I2S0.conf.tx_msb_right = 0; + I2S0.conf.tx_msb_shift = 0; + I2S0.conf.tx_mono = 0; + I2S0.conf.tx_short_sync = 0; + I2S0.timing.val = 0; + + // Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK. + const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs}; + for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) { + routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false); + } + routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK); + + // EOF interrupt refills the ring. IRAM so it keeps running during flash writes. + I2S0.int_ena.val = 0; + I2S0.int_clr.val = I2S0.int_raw.val; + if (esp_intr_alloc(ETS_I2S0_INTR_SOURCE, ESP_INTR_FLAG_IRAM, &EL512Panel::isr, this, + &_intr) != ESP_OK) { + _intr = nullptr; + return false; + } + I2S0.int_ena.out_eof = 1; + + // Start the DMA; it loops over the chain until stopI2S(). + I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M; + I2S0.out_link.addr = (uint32_t)&_desc[0]; + I2S0.out_link.start = 1; + I2S0.conf.tx_start = 1; + return true; +} + +void EL512Panel::stopI2S() { + if (!_desc) return; // never started + + I2S0.int_ena.val = 0; + if (_intr) { + esp_intr_free(_intr); + _intr = nullptr; + } + I2S0.conf.tx_start = 0; + I2S0.out_link.stop = 1; + resetI2S0(); + periph_module_disable(PERIPH_I2S0_MODULE); + + // Hand the pins back to GPIO and park them low. + const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs}; + for (int8_t pin : pins) { + if (pin < 0) continue; + esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false); + gpio_set_level((gpio_num_t)pin, 0); + } + + heap_caps_free(_desc); + _desc = nullptr; +} diff --git a/components/el512/el512_panel.h b/components/el512/el512_panel.h new file mode 100644 index 0000000..2cc329e --- /dev/null +++ b/components/el512/el512_panel.h @@ -0,0 +1,128 @@ +// EL512Panel - refresh driver for the Planar EL512.256-H on an ESP32. +// +// Uses only ESP-IDF APIs, so the same code builds for Arduino/PlatformIO +// (lib/EL512) and for the ESPHome component in this folder. +// +// The panel has no frame memory: it must be refreshed continuously. I2S0 in +// 8-bit parallel (LCD) mode streams the signals by DMA from a small ring of +// encoded lines (CHUNK_COUNT chunks of LINES_PER_CHUNK lines). When a chunk has +// been sent, the DMA EOF interrupt re-encodes it with the lines that come +// RING_LINES later, so the ring stays ~1.7 ms ahead of the panel. +// +// byte bit 0 = VID (even columns 2..512 = x 1, 3, ..., 511) +// byte bit 1 = TVID (odd columns 1..511 = x 0, 2, ..., 510) +// byte bit 2 = HS (high while the 256 data clocks of a row are sent) +// byte bit 3 = VS (marks the top row) +// I2S BCK -> VCLK (inverted, see the .cpp) +// +// BCK rises once per two stream bytes (measured with PCNT), so every VCLK +// period is two identical bytes; the I2S byte rate is 10 MHz for a 5 MHz VCLK. +// In 8-bit LCD mode the I2S emits each 32-bit DMA word as bytes 2, 3, 0, 1, so +// stream byte i is stored at index i ^ 2. +// +// Panel in two-bit parallel mode (PS1 jumper 2 removed, datasheet Table 3): +// 272 clocks per row (256 data + 16 blanking), 261 rows per frame +// (256 visible + 5 blanking) -> 5e6 / (272 * 261) ~= 70.4 Hz. +// +// Drawing goes into a back buffer; show() copies it to the displayed buffer at +// the start of the next frame, so a redraw never shows half-drawn. +#pragma once + +#include +#include + +struct lldesc_s; // ESP32 DMA descriptor +struct intr_handle_data_t; // esp_intr_alloc handle + +struct EL512Pins { + int8_t vclk; + int8_t vid; + int8_t tvid; + int8_t hs; + int8_t vs; + int8_t enable; // -1 if not connected (panel pulls it high) + int8_t lowpwr_n; // -1 if not connected (panel pulls it high) +}; + +class EL512Panel { + public: + static constexpr uint16_t WIDTH = 512; + static constexpr uint16_t HEIGHT = 256; + + // Timing (two-bit parallel mode) + static constexpr uint32_t PIXEL_CLOCK_HZ = 5000000; + static constexpr uint16_t DATA_CLOCKS_PER_ROW = WIDTH / 2; // 2 px per VCLK + static constexpr uint16_t CLOCKS_PER_ROW = 272; + static constexpr uint16_t BLANK_CLOCKS_PER_ROW = CLOCKS_PER_ROW - DATA_CLOCKS_PER_ROW; + static constexpr uint16_t LINES_PER_FRAME = 261; + static constexpr uint16_t BLANK_LINES = LINES_PER_FRAME - HEIGHT; + + // DMA stream byte bits + static constexpr uint8_t BIT_VID = 1 << 0; + static constexpr uint8_t BIT_TVID = 1 << 1; + static constexpr uint8_t BIT_HS = 1 << 2; + static constexpr uint8_t BIT_VS = 1 << 3; + + // Stream bytes per VCLK period (BCK runs at half the I2S byte rate). + static constexpr uint8_t BYTES_PER_CLOCK = 2; + static constexpr uint16_t ROW_BYTES = CLOCKS_PER_ROW * BYTES_PER_CLOCK; + + // DMA ring: CHUNK_COUNT descriptors of LINES_PER_CHUNK lines each. + static constexpr uint16_t LINES_PER_CHUNK = 4; + static constexpr uint16_t CHUNK_COUNT = 8; + static constexpr uint16_t RING_LINES = LINES_PER_CHUNK * CHUNK_COUNT; + static constexpr size_t RING_BYTES = (size_t)RING_LINES * ROW_BYTES; + + static constexpr size_t FRAMEBUFFER_BYTES = (size_t)WIDTH * HEIGHT / 8; + + explicit EL512Panel(const EL512Pins& pins); + ~EL512Panel(); + + // Allocates buffers, sets up control GPIOs and starts the I2S DMA refresh. + bool begin(); + void end(); + bool running() const { return _running; } + + // Display on/off (ENABLE) and low-power mode (_LOWPWR, active low). + void setEnabled(bool on); + void setLowPower(bool on); + bool enabled() const { return _enabled; } + bool lowPower() const { return _lowPower; } + + // 1 bpp drawing buffer, row-major, MSB = leftmost pixel. + uint8_t* framebuffer() { return _fb; } + void setPixel(int16_t x, int16_t y, bool on); + bool getPixel(int16_t x, int16_t y) const; + void clear(bool on = false); + + // Hand the drawing buffer to the refresh. Copies it at the start of the next + // frame and waits for that (at most one frame, ~14 ms). + void show(); + + // Encode one scan line (0..LINES_PER_FRAME-1) of the displayed buffer into + // ROW_BYTES bytes in DMA order. Lines >= HEIGHT produce blanking. + void encodeRow(uint16_t line, uint8_t* out) const; + + // Frame refresh rate implied by the timing constants. + static constexpr float refreshHz() { + return (float)PIXEL_CLOCK_HZ / ((float)CLOCKS_PER_ROW * LINES_PER_FRAME); + } + + private: + bool startI2S(); + void stopI2S(); + void fillChunk(uint16_t chunk); + static void isr(void* arg); + + EL512Pins _pins; + uint8_t* _fb = nullptr; // drawing buffer + uint8_t* _front = nullptr; // buffer being displayed + uint8_t* _ring = nullptr; // RING_BYTES, DMA-capable + lldesc_s* _desc = nullptr; // CHUNK_COUNT descriptors, circular + intr_handle_data_t* _intr = nullptr; + uint16_t _chunkLine[CHUNK_COUNT] = {}; // first line currently in each chunk + volatile bool _swapPending = false; + bool _running = false; + bool _enabled = false; + bool _lowPower = false; +}; diff --git a/components/el512/library.json b/components/el512/library.json new file mode 100644 index 0000000..daf33a3 --- /dev/null +++ b/components/el512/library.json @@ -0,0 +1,10 @@ +{ + "name": "EL512Core", + "version": "0.2.0", + "description": "Planar EL512.256-H refresh driver core (ESP-IDF API). Shared by lib/EL512 and the ESPHome component in this folder.", + "frameworks": ["arduino", "espidf"], + "platforms": "espressif32", + "build": { + "srcFilter": ["-<*>", "+", "+"] + } +} diff --git a/components/el512/still_alive.cpp b/components/el512/still_alive.cpp new file mode 100644 index 0000000..f77665a --- /dev/null +++ b/components/el512/still_alive.cpp @@ -0,0 +1,252 @@ +#include "still_alive.h" + +#include + +#include "el512_font.h" +#include "el512_panel.h" +#include "still_alive_data.h" + +namespace el512sa { + +namespace { + +uint8_t fit(int16_t px, int16_t cell, uint8_t cap) { + const int n = (px - 2 * TextBox::PAD) / cell; + return n > cap ? cap : (uint8_t) n; +} + +bool due(uint32_t now, uint32_t at) { return (int32_t) (now - at) >= 0; } + +void drawChar(EL512Panel &panel, int16_t x, int16_t y, char c) { + if (c < EL512_FONT_FIRST || c > EL512_FONT_LAST) + c = '?'; + const uint8_t *glyph = &EL512_FONT[(c - EL512_FONT_FIRST) * 5]; + for (int16_t col = 0; col < 5; col++) { + const uint8_t bits = glyph[col]; + for (int16_t row = 0; row < 8; row++) { + if (bits & (1 << row)) + panel.setPixel(x + col, y + row, true); + } + } +} + +} // namespace + +// --- TextBox ----------------------------------------------------------------- + +TextBox::TextBox(int16_t x, int16_t y, int16_t w, int16_t h) + : x_(x), y_(y), w_(w), h_(h), cols_(fit(w, CHAR_W, MAX_COLS)), rows_(fit(h, CHAR_H, MAX_ROWS)) { + clear(); +} + +void TextBox::clear() { + memset(text_, 0, sizeof(text_)); + row_ = 0; + col_ = 0; +} + +void TextBox::put(char c) { + if (c == '\n') { + newLine(); + return; + } + if (col_ >= cols_) + newLine(); + text_[row_][col_++] = c; +} + +void TextBox::print(const char *s) { + while (*s) + put(*s++); +} + +void TextBox::newLine() { + col_ = 0; + if (row_ + 1 < rows_) { + row_++; + return; + } + memmove(text_[0], text_[1], (size_t) (rows_ - 1) * sizeof(text_[0])); + memset(text_[rows_ - 1], 0, sizeof(text_[0])); +} + +void TextBox::draw(EL512Panel &panel, bool cursor, bool frame) const { + if (frame) { + // Dashed outline, 4 px on / 2 px off. + for (int16_t i = 0; i < w_; i++) { + if (i % 6 < 4) { + panel.setPixel(x_ + i, y_, true); + panel.setPixel(x_ + i, y_ + h_ - 1, true); + } + } + for (int16_t i = 0; i < h_; i++) { + if (i % 6 < 4) { + panel.setPixel(x_, y_ + i, true); + panel.setPixel(x_ + w_ - 1, y_ + i, true); + } + } + } + for (uint8_t r = 0; r < rows_; r++) { + for (uint8_t c = 0; c < cols_ && text_[r][c]; c++) { + if (text_[r][c] != ' ') + drawChar(panel, x_ + PAD + c * CHAR_W, y_ + PAD + r * CHAR_H, text_[r][c]); + } + } + if (cursor) { + for (int16_t dy = 6; dy < 8; dy++) + for (int16_t dx = 0; dx < 5; dx++) + panel.setPixel(x_ + PAD + col_ * CHAR_W + dx, y_ + PAD + row_ * CHAR_H + dy, true); + } +} + +// --- StillAlivePlayer ---------------------------------------------------------- + +// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture. +StillAlivePlayer::StillAlivePlayer() + : lyrics_(0, 0, 254, 256), credits_(256, 0, 256, 86), art_(256, 88, 256, 168) {} + +uint32_t StillAlivePlayer::songStartMs() { return START_DELAY_MS + SONG_START_MS; } + +void StillAlivePlayer::start(uint32_t nowMs) { + lyrics_.clear(); + credits_.clear(); + showArt(ART_NONE); + showStart_ = nowMs + START_DELAY_MS; + step_ = 0; + charIdx_ = 0; + stepStarted_ = false; + done_ = false; + doneAt_ = 0; + creditIdx_ = 0; + creditChar_ = 0; + creditLineOpen_ = false; + creditsDone_ = false; + creditLineAt_ = CREDITS_START_MS; + // Credits are typed on the bottom row and scroll up, as in the game. + for (uint8_t r = 1; r < credits_.rows(); r++) + credits_.put('\n'); +} + +bool StillAlivePlayer::finished(uint32_t nowMs) const { return done_ && due(nowMs, doneAt_ + HOLD_MS); } + +void StillAlivePlayer::showArt(int8_t id) { + art_.clear(); + if (id <= ART_NONE || (size_t) id >= ART_COUNT) + return; + const Art &art = ARTS[id]; + size_t width = 0; + for (uint8_t i = 0; i < art.count; i++) { + const size_t len = strlen(art.lines[i]); + if (len > width) + width = len; + } + const uint8_t top = art.count < art_.rows() ? (art_.rows() - art.count) / 2 : 0; + const uint8_t left = width < art_.cols() ? (art_.cols() - width) / 2 : 0; + for (uint8_t r = 0; r < top; r++) + art_.put('\n'); + for (uint8_t i = 0; i < art.count && top + i < art_.rows(); i++) { + if (i > 0) + art_.put('\n'); + for (uint8_t c = 0; c < left; c++) + art_.put(' '); + // Truncate instead of wrapping so a wide picture keeps its shape. + for (const char *p = art.lines[i]; *p && left + (p - art.lines[i]) < art_.cols(); p++) + art_.put(*p); + } +} + +bool StillAlivePlayer::update(uint32_t nowMs) { + bool changed = false; + if (due(nowMs, showStart_)) { + const uint32_t t = nowMs - showStart_; + changed |= updateLyrics(t, nowMs); + changed |= updateCredits(t); + } + const bool blink = (nowMs / CURSOR_BLINK_MS) & 1; + const bool creditsBlink = blink && !creditsDone_ && due(nowMs, showStart_ + CREDITS_START_MS); + changed |= blink != cursor_ || creditsBlink != creditsCursor_; + cursor_ = blink; + creditsCursor_ = creditsBlink; + return changed; +} + +// Type whatever the clock says should be on screen by now. +bool StillAlivePlayer::updateLyrics(uint32_t t, uint32_t nowMs) { + bool changed = false; + while (step_ < LYRIC_COUNT && t >= LYRICS[step_].startMs) { + const LyricStep &s = LYRICS[step_]; + if (!stepStarted_) { + stepStarted_ = true; + if (s.flags & STEP_CLEAR) + lyrics_.clear(); + if (s.art != ART_KEEP) + showArt(s.art); + changed = true; + } + const size_t len = strlen(s.text); + const uint32_t elapsed = t - s.startMs; + const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs; + while (charIdx_ < want) { + lyrics_.put(s.text[charIdx_++]); + changed = true; + } + if (charIdx_ < len) + break; // still typing this step + if (s.flags & STEP_NEWLINE) + lyrics_.put('\n'); + step_++; + charIdx_ = 0; + stepStarted_ = false; + changed = true; + if (step_ == LYRIC_COUNT) { + done_ = true; + doneAt_ = nowMs; + } + } + return changed; +} + +// Each credits line takes CREDIT_CHAR_US per character (an empty line counts as +// one), typed evenly; then the next line starts on a fresh bottom row. +bool StillAlivePlayer::updateCredits(uint32_t t) { + if (creditsDone_ || t < CREDITS_START_MS) + return false; + bool changed = false; + while (true) { + const char *line = CREDITS[creditIdx_]; + const size_t len = strlen(line); + if (!creditLineOpen_) { + creditLineOpen_ = true; + credits_.put('\n'); + changed = true; + } + const uint32_t dur = (uint32_t) ((uint64_t) CREDIT_CHAR_US * (len ? len : 1) / 1000); + const uint32_t elapsed = t - creditLineAt_; + const size_t want = elapsed >= dur ? len : len * elapsed / dur; + while (creditChar_ < want) { + credits_.put(line[creditChar_++]); + changed = true; + } + if (elapsed < dur) + break; + creditLineAt_ += dur; + creditChar_ = 0; + creditLineOpen_ = false; + if (++creditIdx_ == CREDIT_COUNT) { + creditIdx_ = 0; + if (!CREDITS_LOOP) { + creditsDone_ = true; + break; + } + } + } + return changed; +} + +void StillAlivePlayer::draw(EL512Panel &panel) const { + lyrics_.draw(panel, cursor_); + credits_.draw(panel, creditsCursor_); + art_.draw(panel, false, false); // no frame around the picture, as in the game +} + +} // namespace el512sa diff --git a/components/el512/still_alive.h b/components/el512/still_alive.h new file mode 100644 index 0000000..8f63894 --- /dev/null +++ b/components/el512/still_alive.h @@ -0,0 +1,110 @@ +// Portal-style end-credits show for the EL512.256-H: timed song text typed into +// a console on the left, credits typed top right, ASCII art bottom right. +// +// Framework-agnostic: draws straight into an EL512Panel with the built-in 5x7 +// font, so the PlatformIO demo (src/still_alive) and the ESPHome component use +// the same code. The content is in still_alive_data.h, generated by +// tools/import_still_alive.py. +#pragma once + +#include +#include + +class EL512Panel; + +namespace el512sa { + +enum : uint8_t { + STEP_NEWLINE = 1 << 0, // start a new line after the text is typed + STEP_CLEAR = 1 << 1, // clear the console before typing +}; + +// One timed piece of console text. Steps are sorted by startMs. +struct LyricStep { + uint32_t startMs; // from the start of the show + uint16_t durMs; // the text is typed out evenly over this time + uint8_t flags; + int8_t art; // picture to show (index into ARTS), or ART_KEEP + const char *text; +}; + +struct Art { + const char *const *lines; + uint8_t count; +}; + +static constexpr int8_t ART_KEEP = -1; // leave the current picture +static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture + +// Fixed-size character console in the 5x7 font (6x8 cells), optionally inside a +// dashed frame. Text wraps at the right edge and scrolls up past the bottom row. +class TextBox { + public: + static constexpr uint8_t MAX_COLS = 48; + static constexpr uint8_t MAX_ROWS = 32; + static constexpr int16_t PAD = 4; + static constexpr int16_t CHAR_W = 6; + static constexpr int16_t CHAR_H = 8; + + TextBox(int16_t x, int16_t y, int16_t w, int16_t h); + + uint8_t cols() const { return cols_; } + uint8_t rows() const { return rows_; } + + void clear(); + void put(char c); + void print(const char *s); + void draw(EL512Panel &panel, bool cursor, bool frame = true) const; + + private: + void newLine(); + + int16_t x_, y_, w_, h_; + uint8_t cols_, rows_; + uint8_t row_ = 0, col_ = 0; + char text_[MAX_ROWS][MAX_COLS + 1]; +}; + +class StillAlivePlayer { + public: + static constexpr uint32_t START_DELAY_MS = 1000; // after start() + static constexpr uint32_t CURSOR_BLINK_MS = 300; + static constexpr uint32_t HOLD_MS = 10000; // after the last step, before finished() + + StillAlivePlayer(); + + // Restart the show; it begins START_DELAY_MS after nowMs. + void start(uint32_t nowMs); + // Advance to nowMs; true if the screen changed (text, picture or cursor). + bool update(uint32_t nowMs); + // Draw the whole show; the caller clears the panel first and shows it after. + void draw(EL512Panel &panel) const; + // True once the last step has been typed and HOLD_MS has passed. + bool finished(uint32_t nowMs) const; + // When to start the music, relative to start(). + static uint32_t songStartMs(); + + private: + bool updateLyrics(uint32_t t, uint32_t nowMs); + bool updateCredits(uint32_t t); + void showArt(int8_t id); + + TextBox lyrics_; + TextBox credits_; + TextBox art_; + uint32_t showStart_ = 0; + size_t step_ = 0; + size_t charIdx_ = 0; + bool stepStarted_ = false; + bool done_ = false; + uint32_t doneAt_ = 0; + size_t creditIdx_ = 0; + size_t creditChar_ = 0; + bool creditLineOpen_ = false; + bool creditsDone_ = false; + uint32_t creditLineAt_ = 0; + bool cursor_ = false; + bool creditsCursor_ = false; +}; + +} // namespace el512sa diff --git a/src/still_alive/imported.h b/components/el512/still_alive_data.h similarity index 95% rename from src/still_alive/imported.h rename to components/el512/still_alive_data.h index 0990fd1..b411f47 100644 --- a/src/still_alive/imported.h +++ b/components/el512/still_alive_data.h @@ -11,7 +11,9 @@ // Generated by tools/import_still_alive.py - do not edit by hand. #pragma once -#include "show_data.h" +#include "still_alive.h" + +namespace el512sa { static const char* const ART_APERTURE_LINES[] = { " .,-:;//;:=,", @@ -512,28 +514,6 @@ static const LyricStep LYRICS[] = { {195250, 1500, STEP_NEWLINE, ART_KEEP, " and/or registered trademarks of"}, {196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"}, {198250, 1500, STEP_NEWLINE, ART_KEEP, " are property of their respective owners."}, - {200250, 0, STEP_NEWLINE, ART_KEEP, ""}, - {200750, 2000, STEP_NEWLINE, ART_KEEP, " For the full legal info, please refer"}, - {204250, 0, STEP_NEWLINE, ART_KEEP, ""}, - {229250, 0, STEP_NEWLINE, ART_KEEP, ""}, - {229350, 0, STEP_NEWLINE, ART_KEEP, ""}, - {229450, 0, STEP_NEWLINE, ART_KEEP, ""}, - {229750, 4000, STEP_NEWLINE, ART_KEEP, "... is anyone there?"}, - {241750, 3000, STEP_NEWLINE, ART_KEEP, "I think you can leave here now."}, - {245250, 0, STEP_NEWLINE, ART_KEEP, ""}, - {246250, 2000, STEP_NEWLINE, ART_KEEP, "Here would be no profit"}, - {248750, 2000, STEP_NEWLINE, ART_KEEP, "if you stay here more and more."}, - {273939, 0, STEP_CLEAR, ART_KEEP, ""}, - {274439, 0, STEP_NEWLINE, ART_KEEP, ""}, - {274939, 0, STEP_NEWLINE, ART_KEEP, ""}, - {275439, 0, STEP_NEWLINE, ART_KEEP, ""}, - {275939, 5000, STEP_NEWLINE, ART_KEEP, "... will you play a game with me?"}, - {281439, 2000, STEP_NEWLINE, ART_KEEP, "I know a simple game, called"}, - {283939, 0, STEP_NEWLINE, ART_KEEP, ""}, - {284939, 5000, STEP_NEWLINE, ART_KEEP, "// Getting Faster and Faster //"}, - {290939, 2000, STEP_NEWLINE, ART_KEEP, "... I just said this is a simple game."}, - {293439, 500, 0, ART_KEEP, "Actually, "}, - {294939, 3500, 0, ART_KEEP, "THIS IS NOT A GAME. "}, }; static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]); @@ -782,3 +762,5 @@ static constexpr uint32_t SONG_START_MS = 6750; static constexpr uint32_t CREDITS_START_MS = 9000; static constexpr uint32_t CREDIT_CHAR_US = 68624; static constexpr bool CREDITS_LOOP = false; + +} // namespace el512sa diff --git a/esphome/.gitignore b/esphome/.gitignore new file mode 100644 index 0000000..d8b4157 --- /dev/null +++ b/esphome/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/esphome/el512-panel.yaml b/esphome/el512-panel.yaml new file mode 100644 index 0000000..563e658 --- /dev/null +++ b/esphome/el512-panel.yaml @@ -0,0 +1,557 @@ +# Planar EL512.256-H (512x256 amber EL) on a Wemos D1 mini ESP32, as a +# Home Assistant information display. +# +# Needs: secrets.yaml (wifi_ssid, wifi_password, el512_api_key, el512_ota_password) +# and the helper sensors from home_assistant/packages/el512_panel.yaml. +# Wiring: see the README of https://mangavalk.com/mangavalk/planar-EL512.256-H + +substitutions: + name: el512-panel + friendly_name: EL512 Panel + weather: weather.knmi_home + envoy: sensor.envoy_122225067403 + +esphome: + name: ${name} + friendly_name: ${friendly_name} + +esp32: + board: wemos_d1_mini32 + framework: + type: esp-idf + +external_components: + - source: + type: git + url: https://mangavalk.com/mangavalk/planar-EL512.256-H.git + ref: main + components: [el512] + refresh: 0s + +logger: + +api: + encryption: + key: !secret el512_api_key + +ota: + - platform: esphome + password: !secret el512_ota_password + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: ${name} + +time: + - platform: homeassistant + id: ha_time + timezone: Europe/Amsterdam + +globals: + - id: rotation_elapsed + type: int + initial_value: "0" + - id: np_updated + type: uint32_t + initial_value: "0" + +# --- Fonts ------------------------------------------------------------------- +font: + - file: "gfonts://Roboto" + id: f_s + size: 16 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Roboto" + id: f_m + size: 22 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Roboto" + id: f_l + size: 32 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Roboto@500" + id: f_big + size: 48 + glyphs: " 0123456789.,:-+%°CWkhMbits/" + - file: "gfonts://Roboto@300" + id: f_clock + size: 120 + glyphs: "0123456789:" + - file: &mdi + type: web + url: https://github.com/Templarian/MaterialDesign-Webfont/raw/master/fonts/materialdesignicons-webfont.ttf + id: icons_xl + size: 96 + glyphs: &weather_icons + - "\U000F0594" # weather-night + - "\U000F0590" # weather-cloudy + - "\U000F0591" # weather-fog + - "\U000F0592" # weather-hail + - "\U000F0593" # weather-lightning + - "\U000F067E" # weather-lightning-rainy + - "\U000F0595" # weather-partly-cloudy + - "\U000F0596" # weather-pouring + - "\U000F0597" # weather-rainy + - "\U000F0598" # weather-snowy + - "\U000F067F" # weather-snowy-rainy + - "\U000F0599" # weather-sunny + - "\U000F059D" # weather-windy + - "\U000F059E" # weather-windy-variant + - "\U000F05D6" # alert-circle-outline + - file: *mdi + id: icons_l + size: 56 + glyphs: *weather_icons + - file: *mdi + id: icons_m + size: 32 + glyphs: + - "\U000F0A72" # solar-power + - "\U000F0D3E" # transmission-tower + - "\U000F06A1" # home-outline + - "\U000F06BA" # plex + - "\U000F0E17" # calendar-month + - "\U000F040A" # play + - "\U000F0387" # music-note + - "\U000F048D" # server-network + - "\U000F048E" # server-network-off + - "\U000F06B0" # update + - "\U000F054B" # umbrella-outline + - "\U000F072E" # arrow-down-bold + - "\U000F0737" # arrow-up-bold + - file: *mdi + id: icons_s + size: 18 + glyphs: + - "\U000F054B" # umbrella-outline + +# --- Data from Home Assistant ------------------------------------------------- +text_sensor: + - platform: homeassistant + id: weather_cond + entity_id: ${weather} + - platform: homeassistant + id: forecast_days + entity_id: sensor.el512_forecast + attribute: days + - platform: homeassistant + id: agenda_events + entity_id: sensor.el512_agenda + attribute: events + - platform: homeassistant + id: np_state + entity_id: sensor.el512_now_playing + - platform: homeassistant + id: np_info + entity_id: sensor.el512_now_playing + attribute: info + on_value: + - lambda: id(np_updated) = millis(); + - platform: homeassistant + id: plex_server + entity_id: sensor.mangavalkserver + - platform: homeassistant + id: plex_streams + entity_id: sensor.el512_plex_streams + attribute: streams + - platform: homeassistant + id: plex_update + entity_id: update.plex_media_server_mangavalkserver + - platform: homeassistant + id: plex_version + entity_id: update.plex_media_server_mangavalkserver + attribute: installed_version + +sensor: + - platform: homeassistant + id: weather_temp + entity_id: ${weather} + attribute: temperature + - platform: homeassistant + id: weather_hum + entity_id: ${weather} + attribute: humidity + - platform: homeassistant + id: weather_wind + entity_id: ${weather} + attribute: wind_speed + # Plex / Tautulli + - platform: homeassistant + id: plex_watching + entity_id: sensor.mangavalkserver + - platform: homeassistant + id: tautulli_bandwidth + entity_id: sensor.tautulli_total_bandwidth + # Solar (Enphase Envoy) and grid + - platform: homeassistant + id: solar_power + entity_id: ${envoy}_current_power_production + - platform: homeassistant + id: solar_today + entity_id: ${envoy}_today_s_energy_production + - platform: homeassistant + id: house_power + entity_id: ${envoy}_current_power_consumption + - platform: homeassistant + id: grid_import + entity_id: sensor.grid_import_power + - platform: homeassistant + id: grid_export + entity_id: sensor.grid_export_power + # Room temperatures (thermostat zones) + - platform: homeassistant + id: t_woonkamer + entity_id: climate.woonkamer + attribute: current_temperature + - platform: homeassistant + id: t_voorkamer + entity_id: climate.voorkamer + attribute: current_temperature + - platform: homeassistant + id: t_gang + entity_id: climate.vloerverwarm + attribute: current_temperature + - platform: homeassistant + id: t_patrick + entity_id: climate.patrick + attribute: current_temperature + - platform: homeassistant + id: t_badkamer + entity_id: climate.badkamer + attribute: current_temperature + - platform: homeassistant + id: t_stans_paul + entity_id: climate.stans_paul + attribute: current_temperature + - platform: homeassistant + id: t_vloer + entity_id: climate.vloererwarmin + attribute: current_temperature + - platform: homeassistant + id: t_huis + entity_id: climate.vigilantestraat + attribute: current_temperature + +graph: + - id: solar_graph + sensor: solar_power + duration: 16h + width: 496 + height: 118 + x_grid: 2h + y_grid: 1000 + min_value: 0 + +# --- Controls in Home Assistant ------------------------------------------------ +switch: + - platform: template + id: sw_display + name: Display + icon: mdi:monitor + restore_mode: RESTORE_DEFAULT_ON + lambda: return id(panel).is_enabled(); + turn_on_action: + - lambda: id(panel).set_enabled(true); + turn_off_action: + - lambda: id(panel).set_enabled(false); + - platform: template + id: sw_low_power + name: Low power mode + icon: mdi:leaf + restore_mode: RESTORE_DEFAULT_OFF + lambda: return id(panel).is_low_power(); + turn_on_action: + - lambda: id(panel).set_low_power(true); + turn_off_action: + - lambda: id(panel).set_low_power(false); + - platform: template + id: sw_pause + name: Pause page rotation + icon: mdi:pause-circle-outline + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: page_interval + name: Page interval + icon: mdi:timer-outline + unit_of_measurement: s + min_value: 5 + max_value: 300 + step: 5 + initial_value: 20 + optimistic: true + restore_value: true + +text: + - platform: template + id: panel_message + name: Message + icon: mdi:message-text-outline + mode: text + max_length: 100 + optimistic: true + restore_value: true + initial_value: "" + on_value: + - component.update: panel + +select: + - platform: template + id: page_select + name: Page + icon: mdi:book-open-page-variant-outline + options: + - Clock + - Forecast + - Agenda + - Dashboard + - Now playing + - Plex + - Solar + - Still Alive + initial_option: Clock + set_action: + - lambda: |- + id(rotation_elapsed) = 0; + if (x == "Still Alive") { id(panel).play_still_alive(); id(panel).update(); return; } + display::DisplayPage *p = id(page_clock); + if (x == "Forecast") p = id(page_forecast); + else if (x == "Agenda") p = id(page_agenda); + else if (x == "Dashboard") p = id(page_dashboard); + else if (x == "Now playing") p = id(page_media); + else if (x == "Plex") p = id(page_plex); + else if (x == "Solar") p = id(page_solar); + id(panel).show_page(p); + id(panel).update(); + +button: + - platform: template + name: Play Still Alive + icon: mdi:cake-variant-outline + on_press: + - lambda: |- + id(panel).play_still_alive(); + id(panel).update(); + +# Rotate pages; skip Still Alive (start it from HA) and Now playing when idle. +interval: + - interval: 1s + then: + - lambda: |- + if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; } + if (++id(rotation_elapsed) < (int) id(page_interval).state) return; + id(rotation_elapsed) = 0; + for (int i = 0; i < 8; i++) { + id(panel).show_next_page(); + const auto *p = id(panel).get_active_page(); + const std::string &np = id(np_state).state; + if (p == id(page_still_alive)) continue; + if (p == id(page_media) && (np == "idle" || np.empty())) continue; + break; + } + id(panel).update(); + +# --- The panel and its pages ---------------------------------------------------- +display: + - platform: el512 + id: panel + enable_pin: GPIO25 + low_power_pin: GPIO26 + update_interval: 1s + still_alive_page: page_still_alive + on_page_change: + - then: + - lambda: |- + const char *name = "Clock"; + if (to == id(page_forecast)) name = "Forecast"; + else if (to == id(page_agenda)) name = "Agenda"; + else if (to == id(page_dashboard)) name = "Dashboard"; + else if (to == id(page_media)) name = "Now playing"; + else if (to == id(page_plex)) name = "Plex"; + else if (to == id(page_solar)) name = "Solar"; + else if (to == id(page_still_alive)) name = "Still Alive"; + if (id(page_select).current_option() != name) id(page_select).publish_state(name); + pages: + # 1. Clock, date and current weather + - id: page_clock + lambda: |- + auto now = id(ha_time).now(); + if (now.is_valid()) { + it.strftime(150, 100, id(f_clock), TextAlign::CENTER, "%H:%M", now); + it.strftime(150, 192, id(f_m), TextAlign::CENTER, "%A %d %B", now); + } + it.vertical_line(302, 24, 208); + const std::string &cond = id(weather_cond).state; + it.print(408, 64, id(icons_xl), TextAlign::CENTER, el512::weather_icon(cond)); + if (!std::isnan(id(weather_temp).state)) + it.printf(408, 140, id(f_big), TextAlign::CENTER, "%.1f°", id(weather_temp).state); + it.print(408, 184, id(f_m), TextAlign::CENTER, el512::weather_text(cond).c_str()); + if (!std::isnan(id(weather_hum).state)) + it.printf(408, 214, id(f_s), TextAlign::CENTER, "%.0f%% humidity %.0f km/h", + id(weather_hum).state, id(weather_wind).state); + + # 2. Forecast (one column per day) + - id: page_forecast + lambda: |- + auto days = el512::split(id(forecast_days).state, ';'); + const int n = std::min(days.size(), 7); + if (n == 0) { + it.print(256, 128, id(f_m), TextAlign::CENTER, "No forecast (sensor.el512_forecast)"); + return; + } + const int w = 512 / n; + for (int i = 0; i < n; i++) { + const int x = w * i + w / 2; + if (i > 0) it.vertical_line(w * i, 16, 224); + it.print(x, 10, id(f_m), TextAlign::TOP_CENTER, el512::field(days[i], 0).c_str()); + it.print(x, 98, id(icons_l), TextAlign::CENTER, el512::weather_icon(el512::field(days[i], 1))); + it.printf(x, 140, id(f_l), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 2).c_str()); + it.printf(x, 180, id(f_m), TextAlign::TOP_CENTER, "%s°", el512::field(days[i], 3).c_str()); + it.print(x - 4, 224, id(icons_s), TextAlign::TOP_RIGHT, "\U000F054B"); + it.printf(x - 2, 222, id(f_s), TextAlign::TOP_LEFT, "%s%%", el512::field(days[i], 4).c_str()); + } + + # 3. Calendar agenda + - id: page_agenda + lambda: |- + it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F0E17"); + it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Agenda"); + auto now = id(ha_time).now(); + if (now.is_valid()) it.strftime(504, 10, id(f_s), TextAlign::TOP_RIGHT, "%A %d %B", now); + it.horizontal_line(0, 40, 512); + auto events = el512::split(id(agenda_events).state, ';'); + if (events.empty()) { + it.print(256, 140, id(f_m), TextAlign::CENTER, "Nothing planned"); + return; + } + int y = 48; + std::string last_day; + for (auto &e : events) { + if (y > 256 - 24) break; + const std::string day = el512::field(e, 0), time = el512::field(e, 1); + if (day != last_day) { + if (!last_day.empty()) it.horizontal_line(8, y - 2, 496); + it.print(8, y, id(f_m), TextAlign::TOP_LEFT, day.c_str()); + last_day = day; + } + it.print(96, y, id(f_m), TextAlign::TOP_LEFT, time.empty() ? "all day" : time.c_str()); + it.print(176, y, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(e, 2), 330).c_str()); + y += 26; + } + + # 4. Sensor dashboard and message line + - id: page_dashboard + lambda: |- + struct Tile { const char *name; float value; }; + const Tile tiles[9] = { + {"Outside", id(weather_temp).state}, {"Woonkamer", id(t_woonkamer).state}, + {"Voorkamer", id(t_voorkamer).state}, {"Gang", id(t_gang).state}, + {"Patrick", id(t_patrick).state}, {"Badkamer", id(t_badkamer).state}, + {"Stans & Paul", id(t_stans_paul).state}, {"Vloerverwarming", id(t_vloer).state}, + {"Huis", id(t_huis).state}, + }; + for (int i = 0; i < 9; i++) { + const int x = (i % 3) * 171, y = (i / 3) * 68; + it.print(x + 10, y + 6, id(f_s), TextAlign::TOP_LEFT, tiles[i].name); + if (std::isnan(tiles[i].value)) + it.print(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "--"); + else + it.printf(x + 10, y + 24, id(f_l), TextAlign::TOP_LEFT, "%.1f°", tiles[i].value); + } + it.vertical_line(170, 4, 196); + it.vertical_line(341, 4, 196); + it.horizontal_line(0, 210, 512); + const std::string &msg = id(panel_message).state; + if (!msg.empty()) { + it.print(256, 234, id(f_m), TextAlign::CENTER, el512::fit(id(f_m), msg, 496).c_str()); + } else { + const float net = id(grid_import).state - id(grid_export).state; + it.printf(256, 234, id(f_s), TextAlign::CENTER, "Solar %s House %s Grid %s", + el512::watts(id(solar_power).state).c_str(), el512::watts(id(house_power).state).c_str(), + el512::watts(net).c_str()); + } + + # 5. Now playing (whichever media player plays) + - id: page_media + lambda: |- + auto f = el512::split(id(np_info).state, '|'); // title|artist|album|player|pos|dur + if (f.size() < 6 || id(np_state).state == "idle") { + it.print(256, 118, id(icons_m), TextAlign::CENTER, "\U000F0387"); + it.print(256, 160, id(f_m), TextAlign::CENTER, "Nothing playing"); + return; + } + it.print(8, 6, id(icons_m), TextAlign::TOP_LEFT, "\U000F040A"); + it.print(48, 12, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[3], 450).c_str()); + it.print(8, 56, id(f_l), TextAlign::TOP_LEFT, el512::fit(id(f_l), f[0], 496).c_str()); + it.print(8, 104, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), f[1], 496).c_str()); + it.print(8, 136, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), f[2], 496).c_str()); + const int dur = atoi(f[5].c_str()); + int pos = atoi(f[4].c_str()) + (int) ((millis() - id(np_updated)) / 1000); + if (dur > 0) { + pos = std::min(pos, dur); + it.rectangle(8, 190, 496, 14); + it.filled_rectangle(8, 190, 496 * pos / dur, 14); + it.print(8, 212, id(f_s), TextAlign::TOP_LEFT, el512::mmss(pos).c_str()); + it.print(504, 212, id(f_s), TextAlign::TOP_RIGHT, el512::mmss(dur).c_str()); + } + + # 6. Plex: streams, server status (Plex + Tautulli integrations) + - id: page_plex + lambda: |- + const bool online = id(plex_server).state != "unavailable" && !id(plex_server).state.empty(); + it.print(8, 4, id(icons_m), TextAlign::TOP_LEFT, "\U000F06BA"); + it.print(48, 8, id(f_m), TextAlign::TOP_LEFT, "Plex"); + it.print(504, 4, id(icons_m), TextAlign::TOP_RIGHT, online ? "\U000F048D" : "\U000F048E"); + std::string ver = id(plex_version).state; + ver = ver.substr(0, ver.find('-')); + it.printf(462, 10, id(f_s), TextAlign::TOP_RIGHT, "%s%s%s", online ? "online" : "offline", + ver.empty() ? "" : " v", ver.c_str()); + if (id(plex_update).state == "on") + it.print(300, 10, id(f_s), TextAlign::TOP_RIGHT, "update available"); + it.horizontal_line(0, 40, 512); + // Left: stream count and bandwidth + const float n = id(plex_watching).state; + it.printf(70, 100, id(f_big), TextAlign::CENTER, "%.0f", std::isnan(n) ? 0.0f : n); + it.print(70, 134, id(f_s), TextAlign::TOP_CENTER, n == 1 ? "stream" : "streams"); + if (!std::isnan(id(tautulli_bandwidth).state)) + it.printf(70, 170, id(f_m), TextAlign::TOP_CENTER, "%.1f Mbit/s", id(tautulli_bandwidth).state / 1000.0f); + it.vertical_line(140, 48, 200); + // Right: who is watching what + auto streams = el512::split(id(plex_streams).state, ';'); + if (streams.empty()) { + it.print(326, 140, id(f_m), TextAlign::CENTER, online ? "Nobody is watching" : "Server offline"); + return; + } + int y = 50; + for (auto &s : streams) { + if (y > 256 - 48) break; + it.print(152, y, id(f_s), TextAlign::TOP_LEFT, el512::fit(id(f_s), el512::field(s, 0), 352).c_str()); + it.print(152, y + 18, id(f_m), TextAlign::TOP_LEFT, el512::fit(id(f_m), el512::field(s, 1), 352).c_str()); + y += 50; + } + + # 7. Solar: production now and today, house and grid, graph of the last 16 h + - id: page_solar + lambda: |- + it.print(8, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F0A72"); + const float sp = std::max(0.0f, id(solar_power).state); + it.print(50, 0, id(f_big), TextAlign::TOP_LEFT, el512::watts(sp).c_str()); + if (!std::isnan(id(solar_today).state)) + it.printf(50, 54, id(f_s), TextAlign::TOP_LEFT, "today %.2f kWh", id(solar_today).state / 1000.0f); + it.print(300, 8, id(icons_m), TextAlign::TOP_LEFT, "\U000F06A1"); + it.print(340, 12, id(f_m), TextAlign::TOP_LEFT, el512::watts(id(house_power).state).c_str()); + const float net = id(grid_import).state - id(grid_export).state; + it.print(300, 48, id(icons_m), TextAlign::TOP_LEFT, "\U000F0D3E"); + it.print(340, 52, id(f_m), TextAlign::TOP_LEFT, el512::watts(std::fabs(net)).c_str()); + it.print(440, 48, id(icons_m), TextAlign::TOP_LEFT, net > 0 ? "\U000F072E" : "\U000F0737"); + it.graph(8, 128, id(solar_graph)); + it.print(12, 110, id(f_s), TextAlign::TOP_LEFT, "last 16 h"); + + # 8. Portal end credits (started from HA; returns to the previous page) + - id: page_still_alive + lambda: |- + id(panel).draw_still_alive(); diff --git a/esphome/el512-smoke.yaml b/esphome/el512-smoke.yaml new file mode 100644 index 0000000..8cac54e --- /dev/null +++ b/esphome/el512-smoke.yaml @@ -0,0 +1,31 @@ +# Minimal bring-up test for the el512 component (no Wi-Fi). +esphome: + name: el512-smoke + +esp32: + board: wemos_d1_mini32 + framework: + type: esp-idf + +logger: + +external_components: + - source: + type: local + path: ../components + +font: + - file: "gfonts://Roboto Mono" + id: mono + size: 20 + +display: + - platform: el512 + id: panel + update_interval: 1s + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + it.line(0, 0, it.get_width() - 1, it.get_height() - 1); + it.print(10, 10, id(mono), "ESPHome on EL512.256-H"); + it.printf(10, 40, id(mono), "uptime %lus", (unsigned long) (millis() / 1000)); + it.filled_circle(it.get_width() / 2, it.get_height() / 2, 40); diff --git a/home_assistant/packages/el512_panel.yaml b/home_assistant/packages/el512_panel.yaml new file mode 100644 index 0000000..5d86338 --- /dev/null +++ b/home_assistant/packages/el512_panel.yaml @@ -0,0 +1,133 @@ +# Helper sensors for the EL512.256-H ESPHome display (esphome/el512-panel.yaml). +# +# Install: put this file in /config/packages/ and make sure configuration.yaml has +# +# homeassistant: +# packages: !include_dir_named packages +# +# then restart Home Assistant. All lists use the same compact text format so the +# ESP32 can split them cheaply: fields separated by "|", entries by ";". + +template: + # Forecast and agenda come from actions, so they refresh on a timer. + - triggers: + - trigger: time_pattern + minutes: "/15" + - trigger: homeassistant + event: start + - trigger: event + event_type: event_template_reloaded + actions: + - action: weather.get_forecasts + target: + entity_id: weather.knmi_home + data: + type: daily + response_variable: fc + - action: calendar.get_events + target: + entity_id: + - calendar.mangavalk_gmail_com + - calendar.li_s_happy_home + - calendar.feestdagen_in_nederland + data: + duration: + days: 14 + response_variable: cal + sensor: + # days: "Sun|cloudy|12|11|0;Mon|partlycloudy|14|10|20;..." + # weekday|condition|high|low|rain chance % + - name: EL512 forecast + unique_id: el512_forecast + icon: mdi:weather-partly-cloudy + state: "{{ fc['weather.knmi_home'].forecast | count }}" + attributes: + days: >- + {%- set ns = namespace(out=[]) -%} + {%- for d in fc['weather.knmi_home'].forecast[:7] -%} + {%- set ns.out = ns.out + [ + (as_datetime(d.datetime) | as_local).strftime('%a') ~ '|' ~ + d.condition ~ '|' ~ + (d.temperature | round(0) | int) ~ '|' ~ + (d.templow | default(d.temperature) | round(0) | int) ~ '|' ~ + (d.precipitation_probability | default(0) | int)] -%} + {%- endfor -%} + {{ ns.out | join(';') }} + + # events: "Mon 19|11:00|Work;Tue 20||Holiday;..." + # day|start time (empty = all day)|title + - name: EL512 agenda + unique_id: el512_agenda + icon: mdi:calendar + state: >- + {%- set ns = namespace(n=0) -%} + {%- for c in cal.values() -%}{%- set ns.n = ns.n + c.events | count -%}{%- endfor -%} + {{ ns.n }} + attributes: + events: >- + {%- set ns = namespace(ev=[]) -%} + {%- for c in cal.values() -%} + {%- for e in c.events -%}{%- set ns.ev = ns.ev + [e] -%}{%- endfor -%} + {%- endfor -%} + {%- set out = namespace(items=[]) -%} + {%- for e in (ns.ev | sort(attribute='start'))[:10] -%} + {%- if e.start | length == 10 -%} + {%- set day = strptime(e.start, '%Y-%m-%d').strftime('%a %d') -%} + {%- set time = '' -%} + {%- else -%} + {%- set t = as_datetime(e.start) | as_local -%} + {%- set day = t.strftime('%a %d') -%} + {%- set time = t.strftime('%H:%M') -%} + {%- endif -%} + {%- set title = (e.summary | default('')) | replace('|', '/') | replace(';', ',') -%} + {%- set out.items = out.items + [day ~ '|' ~ time ~ '|' ~ title[:40]] -%} + {%- endfor -%} + {{ out.items | join(';') }} + + # Whatever media player is playing right now; position advances every 5 s. + - triggers: + - trigger: time_pattern + seconds: "/5" + sensor: + - name: EL512 now playing + unique_id: el512_now_playing + icon: mdi:play-circle + state: >- + {%- set p = states.media_player | selectattr('state', 'eq', 'playing') | list -%} + {{ p[0].entity_id if p else 'idle' }} + attributes: + # "title|artist|album|player|position s|duration s" + info: >- + {%- set p = states.media_player | selectattr('state', 'eq', 'playing') | list -%} + {%- if p -%} + {%- set a = p[0].attributes -%} + {%- set pos = a.media_position | default(0) -%} + {%- if a.media_position_updated_at is defined and a.media_position_updated_at -%} + {%- set pos = pos + (now() - as_datetime(a.media_position_updated_at)).total_seconds() -%} + {%- endif -%} + {%- set dur = a.media_duration | default(0) -%} + {%- set pos = [pos, dur] | min if dur else pos -%} + {{ [a.media_title | default(a.app_name | default('')), a.media_artist | default(a.media_series_title | default('')), + a.media_album_name | default(''), a.friendly_name | default(''), pos | int, dur | int] + | map('string') | map('replace', '|', '/') | join('|') }} + {%- endif -%} + + # Plex: the server sensor has one attribute per stream ("user - client": "title"). + - sensor: + - name: EL512 Plex streams + unique_id: el512_plex_streams + icon: mdi:plex + state: "{{ states('sensor.mangavalkserver') }}" + attributes: + # "user - client|title;..." + streams: >- + {%- set s = states.sensor.mangavalkserver -%} + {%- set ns = namespace(out=[]) -%} + {%- if s -%} + {%- for k, v in s.attributes.items() + if k not in ['unit_of_measurement', 'friendly_name', 'icon', 'device_class', 'state_class'] -%} + {%- set ns.out = ns.out + [(k | replace('|', '/') | replace(';', ',')) ~ '|' ~ + (v | string | replace('|', '/') | replace(';', ','))] -%} + {%- endfor -%} + {%- endif -%} + {{ ns.out | join(';') }} diff --git a/lib/EL512/src/EL512Panel.cpp b/lib/EL512/src/EL512Panel.cpp deleted file mode 100644 index 68bc1c1..0000000 --- a/lib/EL512/src/EL512Panel.cpp +++ /dev/null @@ -1,261 +0,0 @@ -#include "EL512Panel.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock. -constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000; -constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK; -constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ; -static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0, - "byte rate must divide 160 MHz exactly"); -static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range"); - -// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib). -constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX; - -// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each -// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the -// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified -// on the panel). -constexpr bool INVERT_VCLK = true; - -// One DMA descriptor per stream chunk (descriptor limit 4095 bytes). -static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long"); -static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words"); - -void resetI2S0() { - const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M; - I2S0.lc_conf.val |= lcReset; - I2S0.lc_conf.val &= ~lcReset; - const uint32_t confReset = - I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M; - I2S0.conf.val |= confReset; - I2S0.conf.val &= ~confReset; -} - -void routeOutput(int8_t pin, uint32_t signal, bool invert) { - if (pin < 0) return; - pinMode(pin, OUTPUT); - esp_rom_gpio_connect_out_signal(pin, signal, invert, false); -} - -} // namespace - -EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {} - -EL512Panel::~EL512Panel() { end(); } - -bool EL512Panel::begin() { - if (_running) return true; - - pinMode(_pins.enable, OUTPUT); - pinMode(_pins.lowpwr_n, OUTPUT); - setEnabled(false); - setLowPower(false); - - _fb = (uint8_t*)calloc(FRAMEBUFFER_BYTES, 1); - // Full-frame stream: 261 * 544 = ~142 KB of DMA-capable internal RAM, in chunks. - // (A line ring buffer refilled from the DMA EOF interrupt would cut this - // to a few KB if RAM gets tight.) - bool ok = _fb != nullptr; - for (uint16_t i = 0; ok && i < CHUNK_COUNT; i++) { - _chunks[i] = (uint8_t*)heap_caps_malloc((size_t)chunkLines(i) * ROW_BYTES, - MALLOC_CAP_DMA | MALLOC_CAP_8BIT); - ok = _chunks[i] != nullptr; - } - if (!ok) { - end(); - return false; - } - show(); - - if (!startI2S()) { - end(); - return false; - } - _running = true; - setEnabled(true); - return true; -} - -void EL512Panel::end() { - setEnabled(false); - stopI2S(); - _running = false; - free(_fb); - _fb = nullptr; - for (uint8_t*& chunk : _chunks) { - heap_caps_free(chunk); - chunk = nullptr; - } -} - -uint16_t EL512Panel::chunkLines(uint16_t chunk) { - const uint16_t first = chunk * LINES_PER_CHUNK; - return min(LINES_PER_CHUNK, LINES_PER_FRAME - first); -} - -uint8_t* EL512Panel::linePtr(uint16_t line) const { - return _chunks[line / LINES_PER_CHUNK] + (size_t)(line % LINES_PER_CHUNK) * ROW_BYTES; -} - -void EL512Panel::setEnabled(bool on) { digitalWrite(_pins.enable, on ? HIGH : LOW); } - -void EL512Panel::setLowPower(bool on) { digitalWrite(_pins.lowpwr_n, on ? LOW : HIGH); } - -void EL512Panel::setPixel(int16_t x, int16_t y, bool on) { - if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return; - uint8_t& b = _fb[(y * WIDTH + x) >> 3]; - const uint8_t mask = 0x80 >> (x & 7); - if (on) - b |= mask; - else - b &= ~mask; -} - -bool EL512Panel::getPixel(int16_t x, int16_t y) const { - if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false; - return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7)); -} - -void EL512Panel::clear(bool on) { - if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES); -} - -void EL512Panel::encodeRow(uint16_t line, uint8_t* out) const { - // VS is held high during the last blanking line so that the first HS high - // time ending after its rising edge is row 0 (datasheet: Video Data Input). - // VS rises 1104 clocks after the previous HS fall (T4 >= 1 VCLK) and 272 - // clocks = 54.4 us before the next HS rise (T5 >= 3 us); check on a scope. - const uint8_t vs = (line == LINES_PER_FRAME - 1) ? BIT_VS : 0; - - if (line >= HEIGHT) { - memset(out, vs, ROW_BYTES); - return; - } - - const uint8_t* src = _fb + (size_t)line * (WIDTH / 8); - for (uint16_t c = 0; c < DATA_CLOCKS_PER_ROW; c++) { - // Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so - // x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID). - const uint8_t bits = (uint8_t)(src[c >> 2] << ((c & 3) * 2)); - uint8_t v = BIT_HS | vs; - if (bits & 0x80) v |= BIT_TVID; - if (bits & 0x40) v |= BIT_VID; - // Two bytes per VCLK; 8-bit I2S LCD mode sends each 32-bit word as bytes 2, 3, 0, 1. - const uint16_t i = c * BYTES_PER_CLOCK; - out[i ^ 2] = v; - out[(i + 1) ^ 2] = v; - } - // Blanking is uniform and starts on a word boundary, so it needs no swap. - memset(out + DATA_CLOCKS_PER_ROW * BYTES_PER_CLOCK, vs, BLANK_CLOCKS_PER_ROW * BYTES_PER_CLOCK); -} - -void EL512Panel::show() { - if (!_fb || !_chunks[CHUNK_COUNT - 1]) return; - // TODO: double-buffer or sync to VS to avoid tearing. - for (uint16_t line = 0; line < LINES_PER_FRAME; line++) { - encodeRow(line, linePtr(line)); - } -} - -bool EL512Panel::startI2S() { - // Circular descriptor chain over the stream, one descriptor per chunk. - _desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA); - if (!_desc) return false; - for (size_t i = 0; i < CHUNK_COUNT; i++) { - lldesc_t& d = _desc[i]; - d.size = chunkLines(i) * ROW_BYTES; - d.length = d.size; - d.owner = 1; - d.eof = (i == CHUNK_COUNT - 1); // one EOF per frame - d.buf = _chunks[i]; - d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT]; - } - - periph_module_enable(PERIPH_I2S0_MODULE); - resetI2S0(); - - // 8-bit parallel LCD mode, one byte per BCK period (bitluni ESP32Lib setup; - // the c ^ 2 byte order in encodeRow() depends on these FIFO/channel modes). - I2S0.conf2.val = 0; - I2S0.conf2.lcd_en = 1; - I2S0.conf2.lcd_tx_wrx2_en = 1; - I2S0.conf2.lcd_tx_sdx2_en = 0; - - I2S0.sample_rate_conf.val = 0; - I2S0.sample_rate_conf.tx_bits_mod = 8; - I2S0.sample_rate_conf.tx_bck_div_num = 1; - - // PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK. - I2S0.clkm_conf.val = 0; - I2S0.clkm_conf.clka_en = 0; - I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV; - I2S0.clkm_conf.clkm_div_a = 1; - I2S0.clkm_conf.clkm_div_b = 0; - - I2S0.fifo_conf.val = 0; - I2S0.fifo_conf.tx_fifo_mod_force_en = 1; - I2S0.fifo_conf.tx_fifo_mod = 1; - I2S0.fifo_conf.tx_data_num = 32; - I2S0.fifo_conf.dscr_en = 1; - - I2S0.conf1.val = 0; - I2S0.conf1.tx_stop_en = 0; - I2S0.conf1.tx_pcm_bypass = 1; - - I2S0.conf_chan.val = 0; - I2S0.conf_chan.tx_chan_mod = 1; - - I2S0.conf.tx_right_first = 1; - I2S0.conf.tx_msb_right = 0; - I2S0.conf.tx_msb_shift = 0; - I2S0.conf.tx_mono = 0; - I2S0.conf.tx_short_sync = 0; - I2S0.timing.val = 0; - - // Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK. - const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs}; - for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) { - routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false); - } - routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK); - - // Start the DMA; it loops over the chain until stopI2S(). - I2S0.int_ena.val = 0; - I2S0.int_clr.val = I2S0.int_raw.val; - I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M; - I2S0.out_link.addr = (uint32_t)&_desc[0]; - I2S0.out_link.start = 1; - I2S0.conf.tx_start = 1; - return true; -} - -void EL512Panel::stopI2S() { - if (!_desc) return; // never started - - I2S0.conf.tx_start = 0; - I2S0.out_link.stop = 1; - resetI2S0(); - periph_module_disable(PERIPH_I2S0_MODULE); - - // Hand the pins back to GPIO and park them low. - const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs}; - for (int8_t pin : pins) { - if (pin < 0) continue; - esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false); - digitalWrite(pin, LOW); - } - - heap_caps_free(_desc); - _desc = nullptr; -} diff --git a/lib/EL512/src/EL512Panel.h b/lib/EL512/src/EL512Panel.h index dd2222b..74546d3 100644 --- a/lib/EL512/src/EL512Panel.h +++ b/lib/EL512/src/EL512Panel.h @@ -1,109 +1,5 @@ -// EL512Panel - low-level refresh layer for the Planar EL512.256-H. -// -// The panel has no frame memory of its own: it must be refreshed continuously. -// We drive it from I2S0 in 8-bit parallel (LCD) mode with a circular DMA -// descriptor chain. BCK (= VCLK) rises once per two stream bytes (measured -// with PCNT), so every VCLK period is two identical bytes: -// -// byte bit 0 = VID (even columns 2..512 = x 1, 3, ..., 511) -// byte bit 1 = TVID (odd columns 1..511 = x 0, 2, ..., 510) -// byte bit 2 = HS (high while the 256 data clocks of a row are sent) -// byte bit 3 = VS (marks the top row) -// I2S BCK -> VCLK (panel samples data on the rising edge) -// -// Panel in two-bit parallel mode (PS1 jumper 2 removed, datasheet Table 3), 5 MHz VCLK: -// 272 clocks per row (256 data + 16 blanking), 261 rows per frame -// (256 visible + 5 blanking) -> 5e6 / (272 * 261) ~= 70.4 Hz. -// -// In 8-bit LCD mode the ESP32 I2S emits each 32-bit DMA word as bytes 2, 3, 0, 1, -// so encodeRow() stores stream byte i at index i ^ 2 (as bitluni's ESP32Lib does). -// The I2S byte rate is 2 * PIXEL_CLOCK_HZ = 10 MHz. +// The panel driver core lives in components/el512/ so the ESPHome component and +// this Arduino library share one copy (see platformio.ini: lib_extra_dirs). #pragma once -#include - -struct lldesc_s; // ESP32 DMA descriptor (rom/lldesc.h) - -struct EL512Pins { - int8_t vclk; - int8_t vid; - int8_t tvid; - int8_t hs; - int8_t vs; - int8_t enable; - int8_t lowpwr_n; -}; - -class EL512Panel { - public: - static constexpr uint16_t WIDTH = 512; - static constexpr uint16_t HEIGHT = 256; - - // Timing (two-bit parallel mode) - static constexpr uint32_t PIXEL_CLOCK_HZ = 5000000; - static constexpr uint16_t DATA_CLOCKS_PER_ROW = WIDTH / 2; // 2 px per VCLK - static constexpr uint16_t CLOCKS_PER_ROW = 272; - static constexpr uint16_t BLANK_CLOCKS_PER_ROW = CLOCKS_PER_ROW - DATA_CLOCKS_PER_ROW; - static constexpr uint16_t LINES_PER_FRAME = 261; - static constexpr uint16_t BLANK_LINES = LINES_PER_FRAME - HEIGHT; - - // DMA stream byte bits - static constexpr uint8_t BIT_VID = 1 << 0; - static constexpr uint8_t BIT_TVID = 1 << 1; - static constexpr uint8_t BIT_HS = 1 << 2; - static constexpr uint8_t BIT_VS = 1 << 3; - - // Stream bytes per VCLK period (BCK runs at half the I2S byte rate). - static constexpr uint8_t BYTES_PER_CLOCK = 2; - static constexpr uint16_t ROW_BYTES = CLOCKS_PER_ROW * BYTES_PER_CLOCK; - - static constexpr size_t FRAMEBUFFER_BYTES = (size_t)WIDTH * HEIGHT / 8; - static constexpr size_t STREAM_BYTES = (size_t)LINES_PER_FRAME * ROW_BYTES; - - // The ~142 KB stream is too big for one DMA block, so it is allocated in - // chunks of whole lines, one per DMA descriptor (7 * 544 = 3808 <= 4092 bytes). - static constexpr uint16_t LINES_PER_CHUNK = 7; - static constexpr uint16_t CHUNK_COUNT = (LINES_PER_FRAME + LINES_PER_CHUNK - 1) / LINES_PER_CHUNK; - - explicit EL512Panel(const EL512Pins& pins); - ~EL512Panel(); - - // Allocates buffers, sets up control GPIOs and starts the I2S DMA refresh. - bool begin(); - void end(); - - // Display on/off (ENABLE) and low-power mode (_LOWPWR, active low). - void setEnabled(bool on); - void setLowPower(bool on); - - // 1 bpp framebuffer, row-major, MSB = leftmost pixel. - uint8_t* framebuffer() { return _fb; } - void setPixel(int16_t x, int16_t y, bool on); - bool getPixel(int16_t x, int16_t y) const; - void clear(bool on = false); - - // Encode the framebuffer into the DMA stream (call after drawing). - void show(); - - // Encode one scan line (0..LINES_PER_FRAME-1) into ROW_BYTES bytes, in DMA - // order (stream byte i at index i ^ 2). Lines >= HEIGHT produce blanking. - // Public so it can be unit-tested. - void encodeRow(uint16_t line, uint8_t* out) const; - - // Frame refresh rate implied by the timing constants. - static constexpr float refreshHz() { - return (float)PIXEL_CLOCK_HZ / ((float)CLOCKS_PER_ROW * LINES_PER_FRAME); - } - - private: - bool startI2S(); - void stopI2S(); - static uint16_t chunkLines(uint16_t chunk); - uint8_t* linePtr(uint16_t line) const; - - EL512Pins _pins; - uint8_t* _fb = nullptr; // 1 bpp drawing buffer - uint8_t* _chunks[CHUNK_COUNT] = {}; // stream, DMA-capable, LINES_PER_CHUNK lines each - lldesc_s* _desc = nullptr; // circular DMA descriptor chain, one per chunk - bool _running = false; -}; +#include "el512_panel.h" diff --git a/platformio.ini b/platformio.ini index c86701d..3816afc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -11,8 +11,10 @@ framework = arduino monitor_speed = 115200 upload_port = COM20 monitor_port = COM20 +lib_extra_dirs = components ; EL512Core: the shared driver in components/el512 lib_deps = adafruit/Adafruit GFX Library + EL512Core build_flags = -Wall diff --git a/src/still_alive/art.h b/src/still_alive/art.h deleted file mode 100644 index 1d58b82..0000000 --- a/src/still_alive/art.h +++ /dev/null @@ -1,76 +0,0 @@ -// ASCII pictures for the bottom-right box (41 columns x 20 rows fit). -// Each picture is centred in the box. Used when there is no imported.h. -#pragma once - -#include "show_data.h" - -static const char* const ART_CAKE_LINES[] = { - " ) ) )", - " (_) (_) (_)", - " | | |", - " .---|-----|-----|---.", - " |~~~~~~~~~~~~~~~~~~~|", - " | o o o o |", - " |~~~~~~~~~~~~~~~~~~~|", - " | |", - " '-------------------'", -}; - -static const char* const ART_HEART_LINES[] = { - " .-\"\"\"-. .-\"\"\"-.", - " / \\ / \\", - " | V |", - " \\ /", - " '. .'", - " '. .'", - " '. .'", - " '. .'", - " V", -}; - -static const char* const ART_CHECK_LINES[] = { - " ##", - " ###", - " ###", - " ## ###", - " ### ###", - " ### ###", - " ###", - "", - " TEST PASSED", -}; - -static const char* const ART_PANEL_LINES[] = { - " .--------------------------.", - " | EL512.256-H 512 x 256 |", - " | |", - " | VCLK 5.0 MHz |", - " | HS 256 + 16 clk |", - " | VS 70.4 Hz |", - " | |", - " '--------------------------'", - " | | | | |", - " ESP32 I2S0 DMA", -}; - -#define EL_ART(a) {a, sizeof(a) / sizeof(a[0])} - -// Index with the ART_* ids below. -static const Art ARTS[] = { - {nullptr, 0}, - EL_ART(ART_CAKE_LINES), - EL_ART(ART_HEART_LINES), - EL_ART(ART_CHECK_LINES), - EL_ART(ART_PANEL_LINES), -}; - -#undef EL_ART - -static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]); - -enum : int8_t { - ART_CAKE = 1, - ART_HEART, - ART_CHECK, - ART_PANEL, -}; diff --git a/src/still_alive/credits.h b/src/still_alive/credits.h deleted file mode 100644 index bb8c642..0000000 --- a/src/still_alive/credits.h +++ /dev/null @@ -1,41 +0,0 @@ -// Placeholder credits, used when there is no imported.h. Typed into the -// top-right box one line at a time, scrolling up, looping. -// The box is 41 columns wide; longer lines wrap. -#pragma once - -#include -#include - -static constexpr uint32_t CREDITS_START_MS = 0; -static constexpr uint32_t CREDIT_CHAR_US = 68624; // Portal's credits speed -static constexpr bool CREDITS_LOOP = true; - -static const char* const CREDITS[] = { - "", - "PLANAR EL512.256-H", - "512 x 256 electroluminescent", - "", - "Driven by ......... ESP32", - "Refresh ........... I2S0 + DMA", - "Pixel clock ....... 5.0 MHz", - "Frame rate ........ 70.4 Hz", - "Stream ............ 142 KB", - "Graphics .......... Adafruit GFX", - "", - "Bugs found and fixed:", - " BCK at half the byte rate", - " VCLK on the wrong edge", - " one very loose wire", - "", - "Known issues:", - " row stripes go whoosh", - " some burn-in, it is old", - "", - "No panels were harmed", - "in the making of this demo.", - "", - "", - "", -}; - -static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]); diff --git a/src/still_alive/lyrics.h b/src/still_alive/lyrics.h deleted file mode 100644 index 7a4cb18..0000000 --- a/src/still_alive/lyrics.h +++ /dev/null @@ -1,31 +0,0 @@ -// Placeholder console text, used when there is no imported.h. -// -// To play the real song text with its original timing, run -// python tools/import_still_alive.py -// which writes imported.h next to this file (see the script for details). -// -// Or write your own steps here: each one starts at startMs (from the start of -// the show), is typed out evenly over durMs, and can start a new line after it -// (STEP_NEWLINE), clear the console first (STEP_CLEAR) and switch the picture. -// The console is 41 columns x 31 rows; longer lines wrap. -#pragma once - -#include "art.h" -#include "show_data.h" - -static constexpr uint32_t SONG_START_MS = 0; // when to start the music - -static const LyricStep LYRICS[] = { - {0, 2600, STEP_NEWLINE, ART_PANEL, "Forms EL-512256: Panel Bring-up Report"}, - {3000, 0, STEP_NEWLINE, ART_KEEP, ""}, - {3500, 1400, STEP_NEWLINE, ART_KEEP, "Your lyrics go here."}, - {5500, 2100, STEP_NEWLINE, ART_KEEP, "Run tools/import_still_alive.py"}, - {8000, 2600, STEP_NEWLINE, ART_CHECK, "to import them with the song's timing,"}, - {11000, 2300, STEP_NEWLINE, ART_KEEP, "or edit src/still_alive/lyrics.h."}, - {15000, 0, STEP_CLEAR, ART_KEEP, ""}, - {15500, 2300, STEP_NEWLINE, ART_HEART, "Each step starts at its startMs and"}, - {18000, 1500, STEP_NEWLINE, ART_KEEP, "is typed out over durMs."}, - {21000, 2100, STEP_NEWLINE, ART_CAKE, "Reset the board to start over."}, -}; - -static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]); diff --git a/src/still_alive/main.cpp b/src/still_alive/main.cpp index c070b9a..32708b8 100644 --- a/src/still_alive/main.cpp +++ b/src/still_alive/main.cpp @@ -1,21 +1,11 @@ -// Portal-style end-credits console on the EL512.256-H: -// timed lyrics on the left, typed credits top right, ASCII art bottom right. -// -// Content: imported.h if present (generated by tools/import_still_alive.py), -// otherwise the placeholder lyrics.h + art.h + credits.h. +// Portal-style end-credits show on the EL512.256-H (PlatformIO demo). +// The show itself lives in components/el512/still_alive.* and is shared with +// the ESPHome component; its content is components/el512/still_alive_data.h. #include -#include -#include +#include +#include -#if __has_include("imported.h") -#include "imported.h" -#else -#include "art.h" -#include "credits.h" -#include "lyrics.h" -#endif #include "pins.h" -#include "text_box.h" static EL512Panel panel({ el512pins::VCLK, @@ -26,167 +16,26 @@ static EL512Panel panel({ el512pins::ENABLE, el512pins::LOWPWR_N, }); -static EL512_GFX display(panel); - -static constexpr uint32_t START_DELAY_MS = 1000; // after boot or restart -static constexpr uint32_t CURSOR_BLINK_MS = 300; -static constexpr uint32_t RESTART_MS = 10000; // after the last step - -// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture. -static TextBox lyricsBox(0, 0, 254, 256); -static TextBox creditsBox(256, 0, 256, 86); -static TextBox artBox(256, 88, 256, 168); - -static uint32_t showStart = 0; -static size_t step = 0; -static size_t charIdx = 0; -static bool stepStarted = false; -static uint32_t finishedAt = 0; - -static size_t creditIdx = 0; -static size_t creditChar = 0; -static bool creditLineOpen = false; -static bool creditsDone = false; -static uint32_t creditLineAt = 0; // show time the current credits line started - -static bool due(uint32_t now, uint32_t at) { return (int32_t)(now - at) >= 0; } - -static void showArt(int8_t id) { - artBox.clear(); - if (id <= ART_NONE || (size_t)id >= ART_COUNT) return; - const Art& art = ARTS[id]; - size_t width = 0; - for (uint8_t i = 0; i < art.count; i++) width = std::max(width, strlen(art.lines[i])); - const uint8_t top = art.count < artBox.rows() ? (artBox.rows() - art.count) / 2 : 0; - const uint8_t left = width < artBox.cols() ? (artBox.cols() - width) / 2 : 0; - for (uint8_t r = 0; r < top; r++) artBox.put('\n'); - for (uint8_t i = 0; i < art.count && top + i < artBox.rows(); i++) { - if (i > 0) artBox.put('\n'); - for (uint8_t c = 0; c < left; c++) artBox.put(' '); - // Truncate instead of wrapping so a wide picture keeps its shape. - for (const char* p = art.lines[i]; *p && left + (p - art.lines[i]) < artBox.cols(); p++) - artBox.put(*p); - } -} - -static void startShow(uint32_t now) { - lyricsBox.clear(); - creditsBox.clear(); - showArt(ART_NONE); - showStart = now + START_DELAY_MS; - step = 0; - charIdx = 0; - stepStarted = false; - finishedAt = 0; - creditIdx = 0; - creditChar = 0; - creditLineOpen = false; - creditsDone = false; - creditLineAt = CREDITS_START_MS; - // Credits are typed on the bottom row and scroll up, as in the game. - for (uint8_t r = 1; r < creditsBox.rows(); r++) creditsBox.put('\n'); -} - -// Type whatever the clock says should be on screen by now; true if anything changed. -static bool updateLyrics(uint32_t now) { - if (!due(now, showStart)) return false; - const uint32_t t = now - showStart; - bool changed = false; - while (step < LYRIC_COUNT && t >= LYRICS[step].startMs) { - const LyricStep& s = LYRICS[step]; - if (!stepStarted) { - stepStarted = true; - if (s.flags & STEP_CLEAR) lyricsBox.clear(); - if (s.art != ART_KEEP) showArt(s.art); - changed = true; - } - const size_t len = strlen(s.text); - const uint32_t elapsed = t - s.startMs; - const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs; - while (charIdx < want) { - lyricsBox.put(s.text[charIdx++]); - changed = true; - } - if (charIdx < len) break; // still typing this step - if (s.flags & STEP_NEWLINE) lyricsBox.put('\n'); - step++; - charIdx = 0; - stepStarted = false; - changed = true; - if (step == LYRIC_COUNT) finishedAt = now; - } - return changed; -} - -// Credits: each line takes CREDIT_CHAR_US per character (an empty line counts as -// one), typed evenly, then the next line starts on a fresh bottom row. -static bool updateCredits(uint32_t now) { - if (creditsDone || !due(now, showStart)) return false; - const uint32_t t = now - showStart; - if (t < CREDITS_START_MS) return false; - bool changed = false; - while (true) { - const char* line = CREDITS[creditIdx]; - const size_t len = strlen(line); - if (!creditLineOpen) { - creditLineOpen = true; - creditsBox.put('\n'); - changed = true; - } - const uint32_t dur = (uint32_t)((uint64_t)CREDIT_CHAR_US * (len ? len : 1) / 1000); - const uint32_t elapsed = t - creditLineAt; - const size_t want = elapsed >= dur ? len : len * elapsed / dur; - while (creditChar < want) { - creditsBox.put(line[creditChar++]); - changed = true; - } - if (elapsed < dur) break; - creditLineAt += dur; - creditChar = 0; - creditLineOpen = false; - if (++creditIdx == CREDIT_COUNT) { - creditIdx = 0; - if (!CREDITS_LOOP) { - creditsDone = true; - break; - } - } - } - return changed; -} +static el512sa::StillAlivePlayer show; void setup() { Serial.begin(115200); Serial.println("EL512.256-H still alive"); - if (!display.begin()) { + if (!panel.begin()) { Serial.println("panel init failed (out of DMA memory?)"); while (true) delay(1000); } - Serial.printf("%u lyric steps, %u pictures, %u credit lines\n", (unsigned)LYRIC_COUNT, - (unsigned)ART_COUNT, (unsigned)CREDIT_COUNT); - Serial.printf("start the song %lu ms after reset\n", - (unsigned long)(START_DELAY_MS + SONG_START_MS)); - startShow(millis()); + Serial.printf("start the song %lu ms after reset\n", (unsigned long)el512sa::StillAlivePlayer::songStartMs()); + show.start(millis()); } void loop() { - static bool cursorWas = false; const uint32_t now = millis(); - - if (step == LYRIC_COUNT && due(now, finishedAt + RESTART_MS)) startShow(now); - - bool dirty = updateLyrics(now); - dirty |= updateCredits(now); - const bool cursor = (now / CURSOR_BLINK_MS) & 1; - dirty |= cursor != cursorWas; - cursorWas = cursor; - - if (dirty) { - display.fillScreen(0); - lyricsBox.draw(display, cursor); - creditsBox.draw(display, cursor && !creditsDone && due(now, showStart + CREDITS_START_MS)); - artBox.draw(display, false, false); // no frame around the picture, as in the game - display.display(); + if (show.finished(now)) show.start(now); + if (show.update(now)) { + panel.clear(); + show.draw(panel); + panel.show(); } delay(2); } diff --git a/src/still_alive/show_data.h b/src/still_alive/show_data.h deleted file mode 100644 index 6ad738b..0000000 --- a/src/still_alive/show_data.h +++ /dev/null @@ -1,28 +0,0 @@ -// Data types shared by the hand-written show (lyrics.h + art.h) and the imported -// one (imported.h, generated by tools/import_still_alive.py). -#pragma once - -#include -#include - -enum : uint8_t { - STEP_NEWLINE = 1 << 0, // start a new line after the text is typed - STEP_CLEAR = 1 << 1, // clear the console before typing -}; - -// One timed piece of console text. Steps are sorted by startMs. -struct LyricStep { - uint32_t startMs; // from the start of the show - uint16_t durMs; // the text is typed out evenly over this time - uint8_t flags; - int8_t art; // picture to show (index into ARTS), or ART_KEEP - const char* text; -}; - -struct Art { - const char* const* lines; - uint8_t count; -}; - -static constexpr int8_t ART_KEEP = -1; // leave the current picture -static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture diff --git a/src/still_alive/text_box.h b/src/still_alive/text_box.h deleted file mode 100644 index c07999f..0000000 --- a/src/still_alive/text_box.h +++ /dev/null @@ -1,89 +0,0 @@ -// Fixed-size character console in the GFX default 6x8 font, inside a dashed frame. -// Text wraps at the right edge and scrolls up when it runs past the bottom row. -#pragma once - -#include -#include - -class TextBox { - public: - static constexpr uint8_t MAX_COLS = 48; - static constexpr uint8_t MAX_ROWS = 32; - static constexpr int16_t PAD = 4; - static constexpr int16_t CHAR_W = 6; - static constexpr int16_t CHAR_H = 8; - - TextBox(int16_t x, int16_t y, int16_t w, int16_t h) - : _x(x), _y(y), _w(w), _h(h), _cols(fit(w, CHAR_W, MAX_COLS)), _rows(fit(h, CHAR_H, MAX_ROWS)) { - clear(); - } - - uint8_t cols() const { return _cols; } - uint8_t rows() const { return _rows; } - - void clear() { - memset(_text, 0, sizeof(_text)); - _row = 0; - _col = 0; - } - - void put(char c) { - if (c == '\n') { - newLine(); - return; - } - if (_col >= _cols) newLine(); - _text[_row][_col++] = c; - } - - void print(const char* s) { - while (*s) put(*s++); - } - - void draw(Adafruit_GFX& g, bool cursor, bool frame = true) const { - if (frame) drawFrame(g); - g.setTextSize(1); - g.setTextColor(1); - g.setTextWrap(false); - for (uint8_t r = 0; r < _rows; r++) { - g.setCursor(_x + PAD, _y + PAD + r * CHAR_H); - g.print(_text[r]); - } - if (cursor) g.fillRect(_x + PAD + _col * CHAR_W, _y + PAD + _row * CHAR_H + 6, 5, 2, 1); - } - - private: - static uint8_t fit(int16_t px, int16_t cell, uint8_t cap) { - const int n = (px - 2 * PAD) / cell; - return n > cap ? cap : n; - } - - void newLine() { - _col = 0; - if (_row + 1 < _rows) { - _row++; - return; - } - memmove(_text[0], _text[1], (size_t)(_rows - 1) * sizeof(_text[0])); - memset(_text[_rows - 1], 0, sizeof(_text[0])); - } - - // Dashed outline, 4 px on / 2 px off. - void drawFrame(Adafruit_GFX& g) const { - for (int16_t i = 0; i < _w; i += 6) { - const int16_t n = std::min(4, _w - i); - g.drawFastHLine(_x + i, _y, n, 1); - g.drawFastHLine(_x + i, _y + _h - 1, n, 1); - } - for (int16_t i = 0; i < _h; i += 6) { - const int16_t n = std::min(4, _h - i); - g.drawFastVLine(_x, _y + i, n, 1); - g.drawFastVLine(_x + _w - 1, _y + i, n, 1); - } - } - - int16_t _x, _y, _w, _h; - uint8_t _cols, _rows; - uint8_t _row = 0, _col = 0; - char _text[MAX_ROWS][MAX_COLS + 1]; -}; diff --git a/tools/import_still_alive.py b/tools/import_still_alive.py index ec21f57..3d23c1c 100644 --- a/tools/import_still_alive.py +++ b/tools/import_still_alive.py @@ -3,14 +3,15 @@ Reads index.html and res/stillalive.js from https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and -writes src/still_alive/imported.h, which the firmware uses instead of the -placeholder lyrics.h / art.h / credits.h when it exists. +writes components/el512/still_alive_data.h, the content of the Still Alive show +(components/el512/still_alive.cpp) used by both the PlatformIO demo and ESPHome. python tools/import_still_alive.py # download from GitHub python tools/import_still_alive.py --html index.html --js stillalive.js - python tools/import_still_alive.py --out somewhere/imported.h + python tools/import_still_alive.py --out somewhere/still_alive_data.h + python tools/import_still_alive.py --until-ms 0 # keep the web version's outro too -imported.h is committed with a disclaimer and source reference at its top. +still_alive_data.h is committed with a disclaimer and source reference at its top. Source format: each
is one screen (the console clears when it starts). Inside it,