Add four Star Trek / Lower Decks pages with a 3D printer red alert

LCARS ship status, the Cerritos duty roster from the agenda, a warp core
driven by solar power with Rutherford's advice, and a captain's log under
a viewscreen. Original pixel art in tools/trek_sprites.py.

When the 3D printer (Moonraker current_print_state, `printer`
substitution) is paused or in error, the panel jumps to LCARS Status in
red alert and holds the rotation until it carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-19 16:29:29 +02:00
1 parent 4c6286e794
commit 13a499fd97
8 files changed
+811 -8

No files matched your search

+37 -5
View File
@@ -85,6 +85,7 @@ components/el512/ shared driver + ESPHome component (one copy of the code)
cyberpunk.* animated Cyberpunk-style ESPHome pages cyberpunk.* animated Cyberpunk-style ESPHome pages
konosuba.* animated KonoSuba pages; konosuba_sprites.h from tools/ konosuba.* animated KonoSuba pages; konosuba_sprites.h from tools/
steins.* animated Steins;Gate pages; steins_sprites.h from tools/ steins.* animated Steins;Gate pages; steins_sprites.h from tools/
trek.* animated Star Trek / Lower Decks pages; trek_sprites.h from tools/
el512_pages.h home data and helpers shared by the themed pages el512_pages.h home data and helpers shared by the themed pages
el512_display.* ESPHome display platform `el512` el512_display.* ESPHome display platform `el512`
__init__.py, display.py ESPHome config schema __init__.py, display.py ESPHome config schema
@@ -93,7 +94,8 @@ src/still_alive/ Portal-style end-credits demo (env still_alive)
src/test_images/ test sketch: cycles through 12 test images (env esp32dev) src/test_images/ test sketch: cycles through 12 test images (env esp32dev)
esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml
home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages
tools/ import_still_alive.py, konosuba_sprites.py, steins_sprites.py tools/ import_still_alive.py, konosuba_sprites.py, steins_sprites.py,
trek_sprites.py
test/ unit tests (placeholder) test/ unit tests (placeholder)
docs/ datasheet and notes docs/ datasheet and notes
``` ```
@@ -112,10 +114,11 @@ pio device monitor
## ESPHome / Home Assistant ## ESPHome / Home Assistant
[esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home [esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home
Assistant display with twenty pages: clock and weather, forecast, agenda, a sensor Assistant display with twenty-four pages: clock and weather, forecast, agenda, a
dashboard with a message line, now playing, Plex, solar, Still Alive, and twelve sensor dashboard with a message line, now playing, Plex, solar, Still Alive, and
animated themed pages ([Cyberpunk](#cyberpunk-pages), [KonoSuba](#konosuba-pages), sixteen animated themed pages ([Cyberpunk](#cyberpunk-pages),
[Steins;Gate](#steinsgate-pages)). Pages [KonoSuba](#konosuba-pages), [Steins;Gate](#steinsgate-pages),
[Star Trek](#star-trek-pages)). Pages
rotate on an interval, in a random order that is reshuffled every hour; Home Assistant gets a page select, a pause switch, the rotate on an interval, in a random order that is reshuffled every hour; Home Assistant gets a page select, a pause switch, the
interval, a message text, a "Play Still Alive" button, and Display / Low power mode interval, a message text, a "Play Still Alive" button, and Display / Low power mode
switches (ENABLE, _LOWPWR). switches (ENABLE, _LOWPWR).
@@ -277,6 +280,35 @@ The pixel art lives in [tools/steins_sprites.py](tools/steins_sprites.py) (same
These pages are fan art: the pixel art and all texts are original, and no material These pages are fan art: the pixel art and all texts are original, and no material
from the series is used. Steins;Gate is © MAGES. and its other rights holders; this project is not affiliated with or endorsed by them. from the series is used. Steins;Gate is © MAGES. and its other rights holders; this project is not affiliated with or endorsed by them.
## Star Trek pages
Four animated pages aboard a Lower Decks-era ship, drawn by
[components/el512/trek.cpp](components/el512/trek.cpp) with Antonio (standing in for
the LCARS typeface), all in the rotation:
- **LCARS Status**: elbows, blocks and pills around the ship's systems: power
distribution (solar, house, grid), environmental (four rooms), long range sensors
(weather), the fabrication bay (3D printer state), a stardate and a data cascade.
- **Duty Roster**: the U.S.S. Cerritos roster: Mariner, Boimler, Tendi and Rutherford
each get one of your next agenda events as their assignment (or a chore of their
own), with a scrolling shipboard notice.
- **Warp Core**: plasma pulses rush into the reaction chamber faster as solar power
rises; engineering readouts for core output, containment (what the house load leaves)
and the plasma injectors (grid), and Rutherford with advice. A grid import above 3 kW
is a core breach (it's the oven).
- **Captain's Log**: a viewscreen at warp that drops out next to a planet, over a
captain's log typed from the weather, power and the next agenda event.
**Red alert:** when the 3D printer (`printer` substitution, the Moonraker
`current_print_state` sensor) goes to `paused` or `error`, the panel jumps to LCARS
Status in red alert and holds the rotation until the printer carries on.
The pixel art lives in [tools/trek_sprites.py](tools/trek_sprites.py).
These pages are fan art: the pixel art, layouts and texts are original, and no material
from the series is used. Star Trek and Star Trek: Lower Decks are trademarks of CBS
Studios Inc.; this project is not affiliated with or endorsed by them.
## Using the driver ## Using the driver
```cpp ```cpp
+1
View File
@@ -6,6 +6,7 @@
#include "el512_helpers.h" #include "el512_helpers.h"
#include "konosuba.h" #include "konosuba.h"
#include "steins.h" #include "steins.h"
#include "trek.h"
#include "el512_panel.h" #include "el512_panel.h"
#include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_buffer.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
+1
View File
@@ -33,6 +33,7 @@ struct HomeData {
float grid{NAN}; // W, > 0 = import float grid{NAN}; // W, > 0 = import
std::string agenda; // sensor.el512_agenda events: "day|time|title;..." std::string agenda; // sensor.el512_agenda events: "day|time|title;..."
std::string message; // the panel's Message text std::string message; // the panel's Message text
std::string printer; // 3D printer state (Moonraker: printing, paused, error, ...)
Reading rooms[4]; Reading rooms[4];
// Today's solar power in 15-minute slots (highest value seen per slot; -1 = // Today's solar power in 15-minute slots (highest value seen per slot; -1 =
+429
View File
@@ -0,0 +1,429 @@
#include "trek.h"
#include <algorithm>
#include <cmath>
#include <vector>
#include "el512_display.h"
#include "esphome/core/hal.h"
#include "trek_sprites.h"
namespace esphome::el512::trek {
using display::COLOR_OFF;
using display::COLOR_ON;
using display::Display;
using display::TextAlign;
namespace {
using namespace fx;
constexpr int W = 512, H = 256;
const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
// --- LCARS shapes --------------------------------------------------------------------
// Bar with round ends.
void pill(Display &it, int x, int y, int w, int h, Color c = COLOR_ON) {
const int r = h / 2;
it.filled_rectangle(x + r, y, w - 2 * r, h, c);
it.filled_circle(x + r, y + r, r, c);
it.filled_circle(x + w - 1 - r, y + r, r, c);
}
// Lit block with a dark label at its bottom right, as LCARS buttons have.
void block(Display &it, display::BaseFont *font, int x, int y, int w, int h, const char *label) {
it.filled_rectangle(x, y, w, h);
if (label != nullptr)
it.print(x + w - 4, y + h - 1, font, COLOR_OFF, TextAlign::BOTTOM_RIGHT, label);
}
// LCARS elbow: a vertical arm (vw wide) joined to a horizontal arm (hh thick)
// by a large outer curve; the horizontal arm runs along the top (top = true)
// or the bottom of the w x h box.
void elbow(Display &it, int x, int y, int w, int h, int vw, int hh, bool top) {
const int ro = vw * 3 / 4, ri = hh;
const int by = top ? y : y + h - hh; // horizontal arm
it.filled_rectangle(x, by, w, hh);
it.filled_rectangle(x, y, vw, h);
// Round the outer corner.
const int oy = top ? y : y + h - ro;
it.filled_rectangle(x, oy, ro, ro, COLOR_OFF);
it.start_clipping(x, oy, x + ro, oy + ro);
it.filled_circle(x + ro, top ? y + ro : y + h - 1 - ro, ro);
it.end_clipping();
// Fillet the inner corner.
const int iy = top ? by + hh : by - ri;
it.filled_rectangle(x + vw, iy, ri, ri);
it.start_clipping(x + vw, iy, x + vw + ri, iy + ri);
it.filled_circle(x + vw + ri, top ? iy + ri : iy - 1, ri, COLOR_OFF);
it.end_clipping();
}
// Horizontal readout bar: dim track and a lit pill up to frac.
void readout(Display &it, int x, int y, int w, int h, float frac) {
it.horizontal_line(x, y + h / 2, w);
if (!std::isnan(frac) && frac > 0)
pill(it, x, y, std::max(h, (int) (w * std::clamp(frac, 0.0f, 1.0f))), h);
}
std::vector<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> lines;
std::string line;
size_t i = 0;
while (i <= text.size()) {
size_t j = text.find(' ', i);
if (j == std::string::npos)
j = text.size();
const std::string word = text.substr(i, j - i);
const std::string joined = line.empty() ? word : line + " " + word;
if (!line.empty() && text_width(it, font, joined.c_str()) > width) {
lines.push_back(line);
line = word;
} else {
line = joined;
}
i = j + 1;
}
if (!line.empty())
lines.push_back(line);
if (lines.size() > max_lines) {
lines.resize(max_lines);
lines.back() += "...";
}
for (auto &l : lines)
l = fit(font, l, width);
return lines;
}
std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min<size_t>(s.size(), t * cps / 1000)); }
std::string kw(float w) { return std::isnan(w) ? "--" : upper(watts(w)); }
std::string deg(float v) { return std::isnan(v) ? "--" : str("%.1f", v); }
float stardate(ESPTime now) {
if (!now.is_valid())
return 58000.0f;
return 58000.0f + (now.year - 2026) * 1000.0f + (now.day_of_year - 1 + now.hour / 24.0f) * 1000.0f / 365.25f;
}
// LCARS-style serial numbers for blocks and the data cascade.
std::string serial(uint32_t seed) { return str("%02u-%04u", (unsigned) (seed % 100), (unsigned) ((seed >> 8) % 10000)); }
} // namespace
bool red_alert(const HomeData &d) { return d.printer == "paused" || d.printer == "error"; }
// --- 1. LCARS ship status (red alert when the printer needs a look) ----------------------
void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.large == nullptr)
return;
const uint32_t t = it.page_age_ms();
const bool alert = red_alert(d);
const bool blink = (t / 500) % 2;
// Frame: two elbows, sidebar blocks between them, bars along top and bottom.
if (!alert || blink) {
elbow(it, 0, 0, 300, 100, 72, 18, true);
elbow(it, 0, 156, 300, 100, 72, 18, false);
} else {
elbow(it, 0, 0, 300, 100, 72, 6, true);
elbow(it, 0, 156, 300, 100, 72, 6, false);
}
for (int i = 0; i < 2; i++) {
const uint32_t r = hash(i, (t / 1700) * (i + 1));
if (!(alert ? blink : r % 7 == 0))
block(it, f.pixel, 0, 104 + i * 26, 72, 22, serial(hash(i, 4)).c_str());
}
pill(it, 306, 0, 50, 18);
pill(it, 306, 238, 70, 18);
it.print(508, 22, f.medium, TextAlign::BASELINE_RIGHT, alert ? "ALERT CONDITION RED" : "SHIP STATUS");
it.printf(508, 256, f.small, TextAlign::BASELINE_RIGHT, "STARDATE %.1f", stardate(now));
if (alert) {
// Red alert: bars sweep outward from the centre around the warning.
const int cx = 292, cy = 128;
const int phase = (int) (t % 900) * 120 / 900;
for (int k = 0; k < 3; k++) {
const int s = (phase + k * 40) % 120;
it.rectangle(cx - 70 - s, cy - 30 - s / 3, 140 + 2 * s, 60 + 2 * (s / 3));
}
if ((t % 1000) < 700) {
it.filled_rectangle(cx - 110, cy - 34, 220, 68, COLOR_OFF);
it.print(cx, cy + 4, f.large, TextAlign::CENTER, "RED ALERT");
}
it.print(cx, 196, f.medium, TextAlign::TOP_CENTER, d.printer == "error" ? "3D PRINTER ERROR" : "3D PRINTER PAUSED");
it.print(cx, 222, f.small, TextAlign::TOP_CENTER, "REPORT TO THE FABRICATION BAY");
return;
}
// Power distribution.
it.print(84, 24, f.small, TextAlign::TOP_LEFT, "POWER DISTRIBUTION");
const float grid = d.grid;
struct Row {
const char *name;
float w;
};
const Row rows[3] = {{"SOLAR", d.solar},
{"HOUSE", d.house},
{std::isnan(grid) || grid >= 0 ? "GRID IN" : "GRID OUT", std::fabs(grid)}};
for (int i = 0; i < 3; i++) {
const int y = 48 + i * 24;
block(it, f.small, 84, y, 66, 18, rows[i].name);
readout(it, 156, y + 4, 90, 10, rows[i].w / 5000.0f);
it.print(298, y + 18, f.small, TextAlign::BOTTOM_RIGHT, kw(rows[i].w).c_str());
}
// Environmental.
it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4");
for (int i = 0; i < 4; i++) {
const int x = 84 + (i % 2) * 110, y = 150 + (i / 2) * 40;
it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str());
it.print(x, y + 16, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str());
}
// Clock, sensors, fabrication bay.
it.vertical_line(306, 26, 206);
if (now.is_valid())
it.strftime(316, 22, f.large, TextAlign::TOP_LEFT, "%H:%M", now);
it.print(316, 96, f.small, TextAlign::TOP_LEFT, "LONG RANGE SENSORS");
it.printf(316, 114, f.small, TextAlign::TOP_LEFT, "%s %s", deg(d.temp).c_str(), upper(weather_text(d.weather)).c_str());
if (!std::isnan(d.humidity))
it.printf(316, 132, f.small, TextAlign::TOP_LEFT, "HUMIDITY %.0f%%", d.humidity);
it.print(316, 160, f.small, TextAlign::TOP_LEFT, "FABRICATION BAY");
it.print(316, 178, f.small, TextAlign::TOP_LEFT, d.printer.empty() ? "NO SIGNAL" : upper(d.printer).c_str());
// Data cascade: columns of numbers rolling over.
for (int col = 0; col < 2; col++) {
for (int row = 0; row < 9; row++) {
const uint32_t r = hash(col * 31 + row, (t / 180) + row * 3);
const int x = 446 + col * 32, y = 96 + row * 12;
if (r % 5 == 0)
continue;
it.printf(x, y, f.pixel, TextAlign::TOP_LEFT, "%03u", (unsigned) (r % 1000));
}
}
}
// --- 2. Lower Decks duty roster ---------------------------------------------------------
void duty_roster(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.text == nullptr)
return;
const uint32_t t = it.page_age_ms();
pill(it, 0, 0, 40, 18);
it.filled_rectangle(44, 0, 160, 18);
it.print(508, 22, f.medium, TextAlign::BASELINE_RIGHT, "U.S.S. CERRITOS DUTY ROSTER");
struct Ensign {
const char *name;
const Sprite *sprite;
const char *chore;
};
static const Ensign ENSIGNS[4] = {
{"MARINER", &MARINER, "Sneak off to the holodeck before anyone notices"},
{"BOIMLER", &BOIMLER, "Reorganize the cargo bay alphabetically, again"},
{"TENDI", &TENDI, "Run a full diagnostic of the science lab"},
{"RUTHERFORD", &RUTHERFORD, "Scrub the plasma conduits on deck 12"},
};
// The next agenda events go to the ensigns, in order; the rest get chores.
const auto events = split(d.agenda, ';');
const int alarmed = (int) ((t / 5000) % 4); // who gets the "!" this time
for (int i = 0; i < 4; i++) {
const int x = i * 128, cx = x + 64;
const Ensign &e = ENSIGNS[i];
const int bob = ((t / 350 + i) % 2) ? 1 : 0;
sprite(it, *e.sprite, cx - 16, 28 - bob, 2);
if (i == alarmed && (t % 5000) > 3000) {
it.filled_rectangle(cx + 18, 26, 12, 18);
it.print(cx + 24, 35, f.medium, COLOR_OFF, TextAlign::CENTER, "!");
}
pill(it, cx - 54, 80, 108, 18);
it.print(cx, 89, f.small, COLOR_OFF, TextAlign::CENTER, e.name);
std::string title = e.chore, when = "BETA SHIFT";
if ((size_t) i < events.size()) {
title = field(events[i], 2);
const std::string day = field(events[i], 0), time = field(events[i], 1);
when = upper(day) + " " + (time.empty() ? "ALL DAY" : time);
}
if (i > 0)
it.vertical_line(x, 104, 126);
it.print(x + 8, 106, f.pixel, TextAlign::TOP_LEFT, "ASSIGNMENT");
int y = 120;
for (const auto &l : wrap(it, f.text, title, 112, 4)) {
it.print(x + 8, y, f.text, TextAlign::TOP_LEFT, l.c_str());
y += 18;
}
it.print(x + 8, 206, f.pixel, TextAlign::TOP_LEFT, "REPORT");
it.print(x + 8, 218, f.pixel, TextAlign::TOP_LEFT, fit(f.pixel, when, 112).c_str());
}
// Bottom bar with a scrolling notice.
pill(it, 0, 238, 40, 18);
const std::string notice = "ALL ENSIGNS REPORT TO YOUR STATIONS // SENIOR STAFF MEETING IN CONFERENCE ROOM 1 // "
"REMINDER: THE HOLODECK IS NOT A NAP ROOM // ";
const int span = text_width(it, f.small, notice.c_str());
const int nx = 46 - (int) ((t / 25) % (uint32_t) span);
it.start_clipping(46, 236, W, H);
it.print(nx, 256, f.small, TextAlign::BASELINE_LEFT, notice.c_str());
it.print(nx + span, 256, f.small, TextAlign::BASELINE_LEFT, notice.c_str());
it.end_clipping();
}
// --- 3. Warp core and engineering ---------------------------------------------------------
void warp_core(EL512Display &it, const Fonts &f, const HomeData &d) {
if (f.large == nullptr)
return;
const uint32_t t = it.page_age_ms();
const float solar = std::isnan(d.solar) ? 0 : std::max(0.0f, d.solar);
const bool breach = !std::isnan(d.grid) && d.grid > 3000;
const float speed = 40 + std::min(solar, 5000.0f) / 5000.0f * 160 + (breach ? 120 : 0); // px/s
// The core: a column of rings with plasma pulses rushing into the chamber.
constexpr int X0 = 224, X1 = 288, CH0 = 104, CH1 = 152;
it.vertical_line(X0, 0, H);
it.vertical_line(X1, 0, H);
for (int y = 4; y < H; y += 16) {
if (y > CH0 - 8 && y < CH1 + 8)
continue;
it.horizontal_line(X0 - 6, y, X1 - X0 + 13);
it.horizontal_line(X0 + 6, y + 6, X1 - X0 - 11);
}
const uint32_t travel = (uint32_t) (t * speed / 1000);
for (int k = 0; k < 4; k++) {
const int p = (int) ((travel + k * 26) % CH0);
it.filled_rectangle(X0 + 4, p - 3, X1 - X0 - 7, 6); // from the top
it.filled_rectangle(X0 + 4, H - p - 3, X1 - X0 - 7, 6); // from the bottom
}
// Reaction chamber.
it.filled_rectangle(X0 - 16, CH0, X1 - X0 + 33, CH1 - CH0, COLOR_OFF);
it.rectangle(X0 - 16, CH0, X1 - X0 + 33, CH1 - CH0);
const int level = 7 + (int) (6 * std::sin(t * speed / 9000.0f));
for (int y = CH0 + 3; y < CH1 - 3; y++)
for (int x = X0 - 13; x < X1 + 14; x++)
if (BAYER[y & 3][x & 3] < level)
it.draw_pixel_at(x, y, COLOR_ON);
// Engineering readouts on the left.
it.print(0, 0, f.medium, TextAlign::TOP_LEFT, "ENGINEERING");
const float containment = std::isnan(d.house) ? NAN : 1.0f - std::min(d.house, 5000.0f) / 5000.0f;
struct Row {
const char *name;
std::string value;
float frac;
};
const Row rows[] = {
{"WARP CORE OUTPUT", kw(solar), solar / 5000.0f},
{"CONTAINMENT FIELD", std::isnan(containment) ? "--" : str("%.0f%%", containment * 100), containment},
{"PLASMA INJECTORS", kw(std::fabs(d.grid)), std::fabs(d.grid) / 5000.0f},
};
for (int i = 0; i < 3; i++) {
const int y = 36 + i * 44;
it.print(0, y, f.small, TextAlign::TOP_LEFT, rows[i].name);
it.print(204, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str());
readout(it, 0, y + 22, 200, 10, rows[i].frac);
}
it.print(0, 172, f.small, TextAlign::TOP_LEFT, "MATTER / ANTIMATTER 1:1");
it.print(0, 192, f.small, TextAlign::TOP_LEFT, "DILITHIUM MATRIX STABLE");
it.printf(0, 212, f.small, TextAlign::TOP_LEFT, "PULSE RATE %.0f", speed);
// Rutherford with advice.
sprite(it, RUTHERFORD, 382, 158 - ((t / 400) % 2), 3);
const std::string tips[] = {
"Core's purring! The sun's giving us " + kw(solar) + ".",
"Containment's holding. Don't touch the blue button.",
"If it glows brighter, that's normal. Probably.",
"House is pulling " + kw(d.house) + ". Totally manageable.",
};
const std::string &tip = breach ? "Uh... that's a LOT of grid draw. Is the oven on?" : tips[(t / 5000) % 4];
it.rectangle(306, 6, 204, 104);
it.filled_triangle(400, 109, 420, 109, 410, 128);
it.filled_triangle(402, 108, 418, 108, 410, 124, COLOR_OFF);
int y = 14;
for (const auto &l : wrap(it, f.text, typed(tip, t % 5000, 30), 188, 5)) {
it.print(314, y, f.text, TextAlign::TOP_LEFT, l.c_str());
y += 18;
}
if (breach && (t / 300) % 2) {
it.filled_rectangle(0, 236, 300, 20);
it.print(150, 246, f.small, COLOR_OFF, TextAlign::CENTER, "CORE BREACH IMMINENT (IT'S THE OVEN)");
}
}
// --- 4. Captain's log under the viewscreen -----------------------------------------------
void captains_log(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.text == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr uint32_t CYCLE = 18000, DROP = 11000;
const uint32_t c = t % CYCLE;
const bool warp = c < DROP;
constexpr int VX = 4, VY = 2, VW = 504, VH = 146, CX = 256, CY = 74;
// Viewscreen: stars streak past at warp, then drift once we drop out.
it.start_clipping(VX + 2, VY + 2, VX + VW - 2, VY + VH - 2);
for (int i = 0; i < 70; i++) {
const uint32_t r = hash(i, 1701);
const float a = (r % 3600) / 10.0f;
if (warp) {
const float dmod = std::fmod((r >> 12) % 300 + c * (0.12f + (r >> 20) % 10 * 0.02f), 300.0f);
int x0, y0, x1, y1;
polar(CX, CY, dmod, a, &x0, &y0);
polar(CX, CY, dmod + 4 + dmod / 5, a, &x1, &y1);
it.line(x0, y0, x1, y1);
} else {
int x, y;
polar(CX, CY, (float) ((r >> 12) % 300) + (c - DROP) * 0.004f, a, &x, &y);
it.draw_pixel_at(x, y, COLOR_ON);
}
}
if (!warp) {
// A planet slides in after dropping out of warp.
const int px = 600 - (int) std::min<uint32_t>(c - DROP, 4000) / 25;
it.filled_circle(px, 170, 110, COLOR_OFF);
it.circle(px, 170, 110);
for (int y = 60; y < VY + VH; y++)
for (int x = px - 110; x < px; x++)
if ((x - px) * (x - px) + (y - 170) * (y - 170) < 108 * 108 && BAYER[y & 3][x & 3] < 3)
it.draw_pixel_at(x, y, COLOR_ON);
}
it.end_clipping();
const int bob = (t / 600) % 2;
it.filled_rectangle(CX - 50, CY - 13 + bob, 100, 26, COLOR_OFF);
sprite(it, SHIP, CX - 48, CY - 12 + bob, 2);
it.rectangle(VX, VY, VW, VH);
it.rectangle(VX + 1, VY + 1, VW - 2, VH - 2);
if (c >= DROP && c < DROP + 120)
it.invert_rect(VX, VY, VW, VH); // dropping out of warp
it.print(VX + VW - 8, VY + VH - 6, f.pixel, TextAlign::BOTTOM_RIGHT, warp ? "WARP 6" : "FULL STOP");
// Captain's log, written from today's data.
pill(it, 0, 154, 30, 18);
it.printf(36, 154, f.small, TextAlign::TOP_LEFT, "CAPTAIN'S LOG, STARDATE %.1f", stardate(now));
std::string entry = "We are holding position at home.";
if (!std::isnan(d.temp))
entry += str(" Outside it is %.0f degrees", d.temp) +
(d.weather.empty() ? std::string(".") : " and " + weather_text(d.weather) + ".");
if (!std::isnan(d.solar))
entry += " The solar collectors deliver " + watts(d.solar) +
(std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + ".");
const auto events = split(d.agenda, ';');
entry += events.empty() ? " No away missions are scheduled."
: " Next mission: " + field(events[0], 2) + ", " + field(events[0], 0) +
(field(events[0], 1).empty() ? "" : " " + field(events[0], 1)) + ".";
if (d.printer == "printing")
entry += " The fabrication bay is busy printing.";
int y = 178;
for (const auto &l : wrap(it, f.text, typed(entry, c, 34), 500, 4)) {
it.print(6, y, f.text, TextAlign::TOP_LEFT, l.c_str());
y += 19;
}
}
} // namespace esphome::el512::trek
+38
View File
@@ -0,0 +1,38 @@
// Star Trek / Lower Decks-themed animated pages for the ESPHome display: an
// LCARS ship status screen (with red alert when the 3D printer pauses), the
// Cerritos duty roster from the agenda, a warp core driven by solar power,
// and a captain's log under a viewscreen. Fan art: the pixel art
// (trek_sprites.h, from tools/trek_sprites.py), layouts and texts are original.
//
// Each function draws a whole page. Call it from a page lambda and list that
// page under animated_pages; the animation clock is the display's page_age_ms().
#pragma once
#include "el512_pages.h"
#include "esphome/components/display/display.h"
#include "esphome/core/time.h"
namespace esphome::el512 {
class EL512Display;
namespace trek {
struct Fonts {
display::BaseFont *small{nullptr}; // LCARS labels (Antonio 16)
display::BaseFont *medium{nullptr}; // LCARS headers (Antonio 26)
display::BaseFont *large{nullptr}; // LCARS big numbers (Antonio 60)
display::BaseFont *text{nullptr}; // log and roster text (Nunito 16)
display::BaseFont *pixel{nullptr}; // tiny data (Press Start 2P 8)
};
// Red alert: the 3D printer is paused or in error and needs a look.
bool red_alert(const HomeData &d);
void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
void duty_roster(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
void warp_core(EL512Display &it, const Fonts &f, const HomeData &d);
void captains_log(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
} // namespace trek
} // namespace esphome::el512
+137
View File
@@ -0,0 +1,137 @@
// Pixel art for the Star Trek pages, generated by tools/trek_sprites.py;
// edit the art there. Original 1-bit fan art, '#' = lit pixel.
#pragma once
#include "el512_pages.h" // Sprite
namespace esphome::el512::trek {
static const char *const MARINER_ROWS[] = {
".......##.......",
"......####......",
"....########....",
"...##########...",
"...#.#....#.#...",
"...#........#...",
"...#.##..##.#...",
"...#........#...",
"...#......#.#...",
"....#..###.#....",
".....######.....",
"....#######.....",
"..###########...",
".#############..",
".##.###.#####...",
".##.##...###.#..",
".##.#######.##..",
".##.#.....#.##..",
".#..#.....#..#..",
"....#######.....",
"....##...##.....",
"....##...##.....",
"....##...##.....",
"...###...###....",
};
static constexpr Sprite MARINER{16, 24, MARINER_ROWS};
static const char *const BOIMLER_ROWS[] = {
"....#######.....",
"...#########....",
"...######...#...",
"...#........#...",
"...#.##..##.#...",
"...#........#...",
"...#...##...#...",
"...#..####..#...",
"...#..#..#..#...",
"....#.####.#....",
".....######.....",
"....#######.....",
"..###########...",
".#############..",
".##.###.#####...",
".##.##...###.#..",
".##.#######.##..",
".##.#.....#.##..",
".#..#.....#..#..",
"....#######.....",
"....##...##.....",
"....##...##.....",
"....##...##.....",
"...###...###....",
};
static constexpr Sprite BOIMLER{16, 24, BOIMLER_ROWS};
static const char *const TENDI_ROWS[] = {
".....######.....",
"...##########...",
"..############..",
"..##........##..",
"..#..##..##..#..",
"..#..........#..",
"..#.#......#.#..",
"..#..######..#..",
"..##........##..",
"..###......###..",
"..###.####.###..",
"....#######.....",
"..###########...",
".#############..",
".##.###.#####...",
".##.##...###.#..",
".##.#######.##..",
".##.#.....#.##..",
".#..#.....#..#..",
"....#######.....",
"....##...##.....",
"....##...##.....",
"....##...##.....",
"...###...###....",
};
static constexpr Sprite TENDI{16, 24, TENDI_ROWS};
static const char *const RUTHERFORD_ROWS[] = {
"....########....",
"...##########...",
"...#.#.#.#.##...",
"..####......#...",
"..#..#.##...#...",
"..####......#...",
"..####......#...",
"...##...##..#...",
"...#..#####.#...",
"....#......#....",
".....######.....",
"....#######.....",
"..###########...",
".#############..",
".##.###.#####...",
".##.##...###.#..",
".##.#######.##..",
".##.#.....#.##..",
".#..#.....#..#..",
"....#######.....",
"....##...##.....",
"....##...##.....",
"....##...##.....",
"...###...###....",
};
static constexpr Sprite RUTHERFORD{16, 24, RUTHERFORD_ROWS};
static const char *const SHIP_ROWS[] = {
"######################...........................",
"#....................###.........................",
"######################...........................",
".....#.......#...................................",
"......#.......#...................#############..",
".......#.......#...............##################",
"........###########......#######################.",
"........#.........######.........##############..",
"........#.....................#..................",
"........#######################..................",
".........#.....#####.........#...................",
"..........######...##########....................",
};
static constexpr Sprite SHIP{49, 12, SHIP_ROWS};
} // namespace esphome::el512::trek
+59 -3
View File
@@ -10,6 +10,7 @@ substitutions:
friendly_name: EL512 Panel friendly_name: EL512 Panel
weather: weather.knmi_home weather: weather.knmi_home
envoy: sensor.envoy_122225067403 envoy: sensor.envoy_122225067403
printer: sensor.mainsailos_current_print_state # Moonraker; "paused"/"error" = red alert
esphome: esphome:
name: ${name} name: ${name}
@@ -20,6 +21,7 @@ esphome:
id(ks_fonts) = {id(f_ks_title), id(f_ks_big), id(f_ks_text), id(f_ks_pixel), id(f_ks_pixel_l), id(f_ks_jp), id(ks_fonts) = {id(f_ks_title), id(f_ks_big), id(f_ks_text), id(f_ks_pixel), id(f_ks_pixel_l), id(f_ks_jp),
id(f_ks_jp_big), id(icons_l)}; id(f_ks_jp_big), id(icons_l)};
id(sg_fonts) = {id(f_sg_nixie), id(f_sg_title), id(f_cp_mono), id(f_cp_code), id(f_ks_pixel), id(f_sg_jp)}; id(sg_fonts) = {id(f_sg_nixie), id(f_sg_title), id(f_cp_mono), id(f_cp_code), id(f_ks_pixel), id(f_sg_jp)};
id(st_fonts) = {id(f_st_small), id(f_st_medium), id(f_st_large), id(f_ks_text), id(f_ks_pixel)};
esp32: esp32:
board: wemos_d1_mini32 board: wemos_d1_mini32
@@ -65,13 +67,15 @@ globals:
- id: plex_updated - id: plex_updated
type: uint32_t type: uint32_t
initial_value: "0" initial_value: "0"
# Themed pages (Cyberpunk, KonoSuba, Steins;Gate): their fonts and the home data they show. # Themed pages (Cyberpunk, KonoSuba, Steins;Gate, Star Trek): their fonts and the home data they show.
- id: cp_fonts - id: cp_fonts
type: el512::cyber::Fonts type: el512::cyber::Fonts
- id: ks_fonts - id: ks_fonts
type: el512::konosuba::Fonts type: el512::konosuba::Fonts
- id: sg_fonts - id: sg_fonts
type: el512::steins::Fonts type: el512::steins::Fonts
- id: st_fonts
type: el512::trek::Fonts
- id: cp_home - id: cp_home
type: el512::HomeData type: el512::HomeData
@@ -213,6 +217,20 @@ font:
id: f_sg_jp id: f_sg_jp
size: 18 size: 18
glyphs: "未来ガジェット研究所世界線" glyphs: "未来ガジェット研究所世界線"
# Star Trek pages: Antonio stands in for the LCARS typeface (also use
# f_ks_text and f_ks_pixel)
- file: "gfonts://Antonio@600"
id: f_st_small
size: 16
glyphsets: [GF_Latin_Core]
- file: "gfonts://Antonio@600"
id: f_st_medium
size: 26
glyphsets: [GF_Latin_Core]
- file: "gfonts://Antonio@700"
id: f_st_large
size: 60
glyphs: " 0123456789:ADELRT"
# --- Data from Home Assistant ------------------------------------------------- # --- Data from Home Assistant -------------------------------------------------
text_sensor: text_sensor:
@@ -245,6 +263,9 @@ text_sensor:
attribute: streams attribute: streams
on_value: on_value:
- lambda: id(plex_updated) = millis(); - lambda: id(plex_updated) = millis();
- platform: homeassistant
id: printer_state
entity_id: ${printer}
- platform: homeassistant - platform: homeassistant
id: plex_update id: plex_update
entity_id: update.plex_media_server_mangavalkserver entity_id: update.plex_media_server_mangavalkserver
@@ -414,6 +435,10 @@ select:
- D-Mail - D-Mail
- Gadget Lab - Gadget Lab
- World Lines - World Lines
- LCARS Status
- Duty Roster
- Warp Core
- Captain's Log
initial_option: Clock initial_option: Clock
set_action: set_action:
- lambda: |- - lambda: |-
@@ -438,6 +463,10 @@ select:
else if (x == "D-Mail") p = id(page_sg_dmail); else if (x == "D-Mail") p = id(page_sg_dmail);
else if (x == "Gadget Lab") p = id(page_sg_lab); else if (x == "Gadget Lab") p = id(page_sg_lab);
else if (x == "World Lines") p = id(page_sg_world); else if (x == "World Lines") p = id(page_sg_world);
else if (x == "LCARS Status") p = id(page_st_lcars);
else if (x == "Duty Roster") p = id(page_st_roster);
else if (x == "Warp Core") p = id(page_st_warp);
else if (x == "Captain's Log") p = id(page_st_log);
id(panel).show_page(p); id(panel).show_page(p);
id(panel).update(); id(panel).update();
@@ -456,6 +485,13 @@ interval:
- interval: 1s - interval: 1s
then: then:
- lambda: |- - lambda: |-
// Red alert (the 3D printer paused or failed): jump to LCARS once,
// then hold the rotation until the printer carries on.
static bool was_alert = false;
const bool alert = el512::trek::red_alert(id(cp_home));
if (alert && !was_alert) { id(panel).show_page(id(page_st_lcars)); id(panel).update(); }
was_alert = alert;
if (alert) { id(rotation_elapsed) = 0; return; }
if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; } 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; if (++id(rotation_elapsed) < (int) id(page_interval).state) return;
id(rotation_elapsed) = 0; id(rotation_elapsed) = 0;
@@ -470,7 +506,7 @@ interval:
id(page_plex), id(page_solar), id(page_cyber_hud), id(page_cyber_breach), id(page_cyber_scan), id(page_plex), id(page_solar), id(page_cyber_hud), id(page_cyber_breach), id(page_cyber_scan),
id(page_cyber_split), id(page_ks_explosion), id(page_ks_guild), id(page_ks_card), id(page_cyber_split), id(page_ks_explosion), id(page_ks_guild), id(page_ks_card),
id(page_ks_battle), id(page_sg_meter), id(page_sg_dmail), id(page_sg_lab), id(page_sg_world), id(page_ks_battle), id(page_sg_meter), id(page_sg_dmail), id(page_sg_lab), id(page_sg_world),
id(page_still_alive)}; id(page_st_lcars), id(page_st_roster), id(page_st_warp), id(page_st_log), id(page_still_alive)};
for (size_t i = order.size() - 1; i > 0; i--) for (size_t i = order.size() - 1; i > 0; i--)
std::swap(order[i], order[random_uint32() % (i + 1)]); std::swap(order[i], order[random_uint32() % (i + 1)]);
pos = order.size() - 1; // the next step starts at the front pos = order.size() - 1; // the next step starts at the front
@@ -498,6 +534,7 @@ interval:
d.solar_today = id(solar_today).state; d.solar_today = id(solar_today).state;
d.agenda = id(agenda_events).state; d.agenda = id(agenda_events).state;
d.message = id(panel_message).state; d.message = id(panel_message).state;
d.printer = id(printer_state).state;
d.house = id(house_power).state; d.house = id(house_power).state;
d.grid = id(grid_import).state - id(grid_export).state; d.grid = id(grid_import).state - id(grid_export).state;
d.rooms[0] = {"Woonkamer", id(t_woonkamer).state}; d.rooms[0] = {"Woonkamer", id(t_woonkamer).state};
@@ -517,7 +554,8 @@ display:
# Redrawn every animation_interval (~15 fps) while shown. # Redrawn every animation_interval (~15 fps) while shown.
animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split, animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split,
page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle, page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle,
page_sg_meter, page_sg_dmail, page_sg_lab, page_sg_world] page_sg_meter, page_sg_dmail, page_sg_lab, page_sg_world,
page_st_lcars, page_st_roster, page_st_warp, page_st_log]
animation_interval: 66ms animation_interval: 66ms
on_page_change: on_page_change:
- then: - then:
@@ -542,6 +580,10 @@ display:
else if (to == id(page_sg_dmail)) name = "D-Mail"; else if (to == id(page_sg_dmail)) name = "D-Mail";
else if (to == id(page_sg_lab)) name = "Gadget Lab"; else if (to == id(page_sg_lab)) name = "Gadget Lab";
else if (to == id(page_sg_world)) name = "World Lines"; else if (to == id(page_sg_world)) name = "World Lines";
else if (to == id(page_st_lcars)) name = "LCARS Status";
else if (to == id(page_st_roster)) name = "Duty Roster";
else if (to == id(page_st_warp)) name = "Warp Core";
else if (to == id(page_st_log)) name = "Captain's Log";
if (id(page_select).current_option() != name) id(page_select).publish_state(name); if (id(page_select).current_option() != name) id(page_select).publish_state(name);
pages: pages:
# 1. Clock, date and current weather # 1. Clock, date and current weather
@@ -797,3 +839,17 @@ display:
- id: page_sg_world - id: page_sg_world
lambda: |- lambda: |-
el512::steins::world_lines(*id(panel), id(sg_fonts), id(cp_home), id(ha_time).now()); el512::steins::world_lines(*id(panel), id(sg_fonts), id(cp_home), id(ha_time).now());
# 21-24. Star Trek / Lower Decks pages (components/el512/trek.cpp)
- id: page_st_lcars
lambda: |-
el512::trek::lcars_status(*id(panel), id(st_fonts), id(cp_home), id(ha_time).now());
- id: page_st_roster
lambda: |-
el512::trek::duty_roster(*id(panel), id(st_fonts), id(cp_home), id(ha_time).now());
- id: page_st_warp
lambda: |-
el512::trek::warp_core(*id(panel), id(st_fonts), id(cp_home));
- id: page_st_log
lambda: |-
el512::trek::captains_log(*id(panel), id(st_fonts), id(cp_home), id(ha_time).now());
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Pixel art for the Star Trek / Lower Decks pages (components/el512/trek.cpp).
Original 1-bit fan art: '#' is a lit pixel. Writes
components/el512/trek_sprites.h and, with --preview, a magnified PNG.
python tools/trek_sprites.py
python tools/trek_sprites.py --preview sprites.png
"""
from konosuba_sprites import main, padded
# Starfleet uniform shared by the ensigns: lit shoulders, combadge on the chest.
BODY = [
"....#######.....",
"..###########...",
".#############..",
".##.###.#####...",
".##.##...###.#..",
".##.#######.##..",
".##.#.....#.##..",
".#..#.....#..#..",
"....#######.....",
"....##...##.....",
"....##...##.....",
"....##...##.....",
"...###...###....",
]
SPRITES = {
# Mariner: hair up in a bun, a smirk.
"MARINER": [
".......##.......",
"......####......",
"....########....",
"...##########...",
"...#.#....#.#...",
"...#........#...",
"...#.##..##.#...",
"...#........#...",
"...#......#.#...",
"....#..###.#....",
".....######.....",
] + BODY,
# Boimler: neat side part, worried open mouth.
"BOIMLER": [
"....#######.....",
"...#########....",
"...######...#...",
"...#........#...",
"...#.##..##.#...",
"...#........#...",
"...#...##...#...",
"...#..####..#...",
"...#..#..#..#...",
"....#.####.#....",
".....######.....",
] + BODY,
# Tendi: long hair down both sides, big smile.
"TENDI": [
".....######.....",
"...##########...",
"..############..",
"..##........##..",
"..#..##..##..#..",
"..#..........#..",
"..#.#......#.#..",
"..#..######..#..",
"..##........##..",
"..###......###..",
"..###.####.###..",
] + BODY,
# Rutherford: cybernetic implant over his left eye and temple.
"RUTHERFORD": [
"....########....",
"...##########...",
"...#.#.#.#.##...",
"..####......#...",
"..#..#.##...#...",
"..####......#...",
"..####......#...",
"...##...##..#...",
"...#..#####.#...",
"....#......#....",
".....######.....",
] + BODY,
# A Starfleet ship in profile, heading right: nacelle, pylons, engineering
# hull and saucer.
"SHIP": [
"######################..........................",
"#....................###........................",
"######################..........................",
".....#.......#..................................",
"......#.......#...................#############.",
".......#.......#...............##################",
"........###########......#######################.",
"........#.........######.........##############..",
"........#.....................#..................",
"........#######################..................",
".........#.....#####.........#...................",
"..........######...##########....................",
],
}
for _name in SPRITES:
SPRITES[_name] = padded(SPRITES[_name])
if __name__ == "__main__":
main(SPRITES, "Star Trek", "trek_sprites.py", "trek", __doc__)