Public Access
Add ESPHome display component; ring-buffer driver shared with PlatformIO
Driver (components/el512/el512_panel.*, ESP-IDF only): - DMA ring of 8 x 4 lines (17 KB) refilled from the I2S EOF interrupt (IRAM) instead of a 142 KB full-frame stream; ~107 KB heap freed. - 256-entry lookup table encodes 8 pixels per step. - Back buffer + displayed buffer, swapped at the start of a frame. - PlatformIO builds it via lib_extra_dirs (EL512Core); lib/EL512 keeps the Adafruit GFX front end. ESPHome external component (platform: el512): - DisplayBuffer with optional ENABLE / _LOWPWR pins, set_enabled(), set_low_power(), helper functions for page lambdas. - still_alive_page: plays the Portal end-credits show, then returns to the previous page. Still Alive engine moved to components/el512/still_alive.* with a built-in 5x7 font (from Adafruit GFX, BSD); the PlatformIO demo uses the same code. The importer writes components/el512/still_alive_data.h and stops at 200 s (--until-ms), matching the hand-trimmed data. esphome/el512-panel.yaml: 8 pages (clock, forecast, agenda, dashboard, now playing, Plex, solar, Still Alive), page select, rotation, display and low-power switches, message text. home_assistant/packages/el512_panel.yaml: helper template sensors for forecast, agenda, now playing and Plex streams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
410885de24
commit
963590cdfe
27 files changed
+2110
-828
No files matched your search
+13
@@ -24,3 +24,16 @@ $RECYCLE.BIN/
|
|||||||
# Editors
|
# Editors
|
||||||
*.swp
|
*.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__/
|
||||||
@@ -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")
|
||||||
@@ -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_))
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include "el512_display.h"
|
||||||
|
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -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
|
||||||
@@ -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 <stdint.h>
|
||||||
|
|
||||||
|
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, // ~
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Small helpers for page lambdas (available as el512::... in YAML lambdas).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<std::string> split(const std::string &s, char sep) {
|
||||||
|
std::vector<std::string> 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
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#include "el512_panel.h"
|
||||||
|
|
||||||
|
#include <driver/gpio.h>
|
||||||
|
#include <esp_attr.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include <esp_intr_alloc.h>
|
||||||
|
#include <esp_rom_gpio.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
#include <soc/gpio_sig_map.h>
|
||||||
|
#include <soc/i2s_reg.h>
|
||||||
|
#include <soc/i2s_struct.h>
|
||||||
|
#include <soc/periph_defs.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#if __has_include(<esp_private/periph_ctrl.h>)
|
||||||
|
#include <esp_private/periph_ctrl.h>
|
||||||
|
#else
|
||||||
|
#include <driver/periph_ctrl.h>
|
||||||
|
#endif
|
||||||
|
#if __has_include(<esp32/rom/lldesc.h>)
|
||||||
|
#include <esp32/rom/lldesc.h>
|
||||||
|
#else
|
||||||
|
#include <rom/lldesc.h>
|
||||||
|
#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<EL512Panel*>(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;
|
||||||
|
}
|
||||||
@@ -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 <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -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": ["-<*>", "+<el512_panel.cpp>", "+<still_alive.cpp>"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#include "still_alive.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -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 <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
// Generated by tools/import_still_alive.py - do not edit by hand.
|
// Generated by tools/import_still_alive.py - do not edit by hand.
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "show_data.h"
|
#include "still_alive.h"
|
||||||
|
|
||||||
|
namespace el512sa {
|
||||||
|
|
||||||
static const char* const ART_APERTURE_LINES[] = {
|
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"},
|
{195250, 1500, STEP_NEWLINE, ART_KEEP, " and/or registered trademarks of"},
|
||||||
{196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"},
|
{196750, 1500, STEP_NEWLINE, ART_KEEP, " Valve Corporation. All other trademarks"},
|
||||||
{198250, 1500, STEP_NEWLINE, ART_KEEP, " are property of their respective owners."},
|
{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]);
|
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 CREDITS_START_MS = 9000;
|
||||||
static constexpr uint32_t CREDIT_CHAR_US = 68624;
|
static constexpr uint32_t CREDIT_CHAR_US = 68624;
|
||||||
static constexpr bool CREDITS_LOOP = false;
|
static constexpr bool CREDITS_LOOP = false;
|
||||||
|
|
||||||
|
} // namespace el512sa
|
||||||
@@ -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
|
||||||
@@ -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<int>(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();
|
||||||
@@ -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);
|
||||||
@@ -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(';') }}
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
#include "EL512Panel.h"
|
|
||||||
|
|
||||||
#include <driver/periph_ctrl.h>
|
|
||||||
#include <esp32/rom/lldesc.h>
|
|
||||||
#include <esp_heap_caps.h>
|
|
||||||
#include <esp_rom_gpio.h>
|
|
||||||
#include <soc/gpio_sig_map.h>
|
|
||||||
#include <soc/i2s_reg.h>
|
|
||||||
#include <soc/i2s_struct.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
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<uint16_t>(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;
|
|
||||||
}
|
|
||||||
+3
-107
@@ -1,109 +1,5 @@
|
|||||||
// EL512Panel - low-level refresh layer for the Planar EL512.256-H.
|
// 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).
|
||||||
// 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.
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include "el512_panel.h"
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -11,8 +11,10 @@ framework = arduino
|
|||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
upload_port = COM20
|
upload_port = COM20
|
||||||
monitor_port = COM20
|
monitor_port = COM20
|
||||||
|
lib_extra_dirs = components ; EL512Core: the shared driver in components/el512
|
||||||
lib_deps =
|
lib_deps =
|
||||||
adafruit/Adafruit GFX Library
|
adafruit/Adafruit GFX Library
|
||||||
|
EL512Core
|
||||||
build_flags =
|
build_flags =
|
||||||
-Wall
|
-Wall
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 <stddef.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
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]);
|
|
||||||
@@ -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]);
|
|
||||||
+14
-165
@@ -1,21 +1,11 @@
|
|||||||
// Portal-style end-credits console on the EL512.256-H:
|
// Portal-style end-credits show on the EL512.256-H (PlatformIO demo).
|
||||||
// timed lyrics on the left, typed credits top right, ASCII art bottom right.
|
// 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.
|
||||||
// Content: imported.h if present (generated by tools/import_still_alive.py),
|
|
||||||
// otherwise the placeholder lyrics.h + art.h + credits.h.
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <EL512.h>
|
#include <el512_panel.h>
|
||||||
#include <string.h>
|
#include <still_alive.h>
|
||||||
|
|
||||||
#if __has_include("imported.h")
|
|
||||||
#include "imported.h"
|
|
||||||
#else
|
|
||||||
#include "art.h"
|
|
||||||
#include "credits.h"
|
|
||||||
#include "lyrics.h"
|
|
||||||
#endif
|
|
||||||
#include "pins.h"
|
#include "pins.h"
|
||||||
#include "text_box.h"
|
|
||||||
|
|
||||||
static EL512Panel panel({
|
static EL512Panel panel({
|
||||||
el512pins::VCLK,
|
el512pins::VCLK,
|
||||||
@@ -26,167 +16,26 @@ static EL512Panel panel({
|
|||||||
el512pins::ENABLE,
|
el512pins::ENABLE,
|
||||||
el512pins::LOWPWR_N,
|
el512pins::LOWPWR_N,
|
||||||
});
|
});
|
||||||
static EL512_GFX display(panel);
|
static el512sa::StillAlivePlayer show;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
Serial.println("EL512.256-H still alive");
|
Serial.println("EL512.256-H still alive");
|
||||||
if (!display.begin()) {
|
if (!panel.begin()) {
|
||||||
Serial.println("panel init failed (out of DMA memory?)");
|
Serial.println("panel init failed (out of DMA memory?)");
|
||||||
while (true) delay(1000);
|
while (true) delay(1000);
|
||||||
}
|
}
|
||||||
Serial.printf("%u lyric steps, %u pictures, %u credit lines\n", (unsigned)LYRIC_COUNT,
|
Serial.printf("start the song %lu ms after reset\n", (unsigned long)el512sa::StillAlivePlayer::songStartMs());
|
||||||
(unsigned)ART_COUNT, (unsigned)CREDIT_COUNT);
|
show.start(millis());
|
||||||
Serial.printf("start the song %lu ms after reset\n",
|
|
||||||
(unsigned long)(START_DELAY_MS + SONG_START_MS));
|
|
||||||
startShow(millis());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
static bool cursorWas = false;
|
|
||||||
const uint32_t now = millis();
|
const uint32_t now = millis();
|
||||||
|
if (show.finished(now)) show.start(now);
|
||||||
if (step == LYRIC_COUNT && due(now, finishedAt + RESTART_MS)) startShow(now);
|
if (show.update(now)) {
|
||||||
|
panel.clear();
|
||||||
bool dirty = updateLyrics(now);
|
show.draw(panel);
|
||||||
dirty |= updateCredits(now);
|
panel.show();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
delay(2);
|
delay(2);
|
||||||
}
|
}
|
||||||
@@ -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 <stddef.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -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 <Adafruit_GFX.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
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<int16_t>(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<int16_t>(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];
|
|
||||||
};
|
|
||||||
@@ -3,14 +3,15 @@
|
|||||||
|
|
||||||
Reads index.html and res/stillalive.js from
|
Reads index.html and res/stillalive.js from
|
||||||
https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and
|
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
|
writes components/el512/still_alive_data.h, the content of the Still Alive show
|
||||||
placeholder lyrics.h / art.h / credits.h when it exists.
|
(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 # download from GitHub
|
||||||
python tools/import_still_alive.py --html index.html --js stillalive.js
|
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 <div class="container_lyrics" data-start=ms> is one screen
|
Source format: each <div class="container_lyrics" data-start=ms> is one screen
|
||||||
(the console clears when it starts). Inside it, <span data-start data-dur
|
(the console clears when it starts). Inside it, <span data-start data-dur
|
||||||
@@ -28,7 +29,11 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
|
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
|
||||||
OUT = Path(__file__).resolve().parent.parent / "src" / "still_alive" / "imported.h"
|
OUT = Path(__file__).resolve().parent.parent / "components" / "el512" / "still_alive_data.h"
|
||||||
|
|
||||||
|
# The show ends after the "... are property of their respective owners." line;
|
||||||
|
# the web version's outro after it ("... is anyone there?") is left out.
|
||||||
|
UNTIL_MS = 200000
|
||||||
|
|
||||||
# Timing from stillalive.js.
|
# Timing from stillalive.js.
|
||||||
SONG_START_MS = 6750
|
SONG_START_MS = 6750
|
||||||
@@ -146,12 +151,16 @@ def main():
|
|||||||
ap.add_argument("--html", help="local index.html (default: download)")
|
ap.add_argument("--html", help="local index.html (default: download)")
|
||||||
ap.add_argument("--js", help="local res/stillalive.js (default: download)")
|
ap.add_argument("--js", help="local res/stillalive.js (default: download)")
|
||||||
ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)")
|
ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)")
|
||||||
|
ap.add_argument("--until-ms", type=int, default=UNTIL_MS,
|
||||||
|
help="drop steps starting at or after this time, 0 = keep all (default: %(default)s)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
page = load(args.html, "index.html")
|
page = load(args.html, "index.html")
|
||||||
arts = parse_art(page)
|
arts = parse_art(page)
|
||||||
art_ids = {name: i + 1 for i, name in enumerate(arts)}
|
art_ids = {name: i + 1 for i, name in enumerate(arts)}
|
||||||
steps = parse_steps(page, art_ids)
|
steps = parse_steps(page, art_ids)
|
||||||
|
if args.until_ms:
|
||||||
|
steps = [s for s in steps if s[0] < args.until_ms]
|
||||||
credits = parse_credits(load(args.js, "res/stillalive.js"))
|
credits = parse_credits(load(args.js, "res/stillalive.js"))
|
||||||
if not steps or not arts or not credits:
|
if not steps or not arts or not credits:
|
||||||
sys.exit("error: no lyrics, art or credits found; has the source changed?")
|
sys.exit("error: no lyrics, art or credits found; has the source changed?")
|
||||||
@@ -168,7 +177,7 @@ def main():
|
|||||||
"// owners. This project is not affiliated with or endorsed by Valve.",
|
"// owners. This project is not affiliated with or endorsed by Valve.",
|
||||||
"//",
|
"//",
|
||||||
"// Generated by tools/import_still_alive.py - do not edit by hand.",
|
"// Generated by tools/import_still_alive.py - do not edit by hand.",
|
||||||
"#pragma once", "", '#include "show_data.h"', ""]
|
"#pragma once", "", '#include "still_alive.h"', "", "namespace el512sa {", ""]
|
||||||
for name, lines in arts.items():
|
for name, lines in arts.items():
|
||||||
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
|
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
|
||||||
out += [f" {c_string(l)}," for l in lines]
|
out += [f" {c_string(l)}," for l in lines]
|
||||||
@@ -187,7 +196,7 @@ def main():
|
|||||||
out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};",
|
out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};",
|
||||||
f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};",
|
f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};",
|
||||||
f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};",
|
f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};",
|
||||||
"static constexpr bool CREDITS_LOOP = false;", ""]
|
"static constexpr bool CREDITS_LOOP = false;", "", "} // namespace el512sa", ""]
|
||||||
|
|
||||||
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
|
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
|
||||||
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")
|
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")
|
||||||
|
|||||||
Reference in new issue
Block a user