From 13a499fd975a13dab022f618f5c7dfe532256db4 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Mon, 19 Oct 2026 16:29:29 +0200 Subject: [PATCH] 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 --- README.md | 42 ++- components/el512/el512_display.h | 1 + components/el512/el512_pages.h | 1 + components/el512/trek.cpp | 429 +++++++++++++++++++++++++++++++ components/el512/trek.h | 38 +++ components/el512/trek_sprites.h | 137 ++++++++++ esphome/el512-panel.yaml | 62 ++++- tools/trek_sprites.py | 109 ++++++++ 8 files changed, 811 insertions(+), 8 deletions(-) create mode 100644 components/el512/trek.cpp create mode 100644 components/el512/trek.h create mode 100644 components/el512/trek_sprites.h create mode 100644 tools/trek_sprites.py diff --git a/README.md b/README.md index 7b51e64..37f562e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ components/el512/ shared driver + ESPHome component (one copy of the code) cyberpunk.* animated Cyberpunk-style ESPHome pages konosuba.* animated KonoSuba pages; konosuba_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_display.* ESPHome display platform `el512` __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) esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml 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) docs/ datasheet and notes ``` @@ -112,10 +114,11 @@ pio device monitor ## ESPHome / Home Assistant [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 -dashboard with a message line, now playing, Plex, solar, Still Alive, and twelve -animated themed pages ([Cyberpunk](#cyberpunk-pages), [KonoSuba](#konosuba-pages), -[Steins;Gate](#steinsgate-pages)). Pages +Assistant display with twenty-four pages: clock and weather, forecast, agenda, a +sensor dashboard with a message line, now playing, Plex, solar, Still Alive, and +sixteen animated themed pages ([Cyberpunk](#cyberpunk-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 interval, a message text, a "Play Still Alive" button, and Display / Low power mode 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 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 ```cpp diff --git a/components/el512/el512_display.h b/components/el512/el512_display.h index 5021cc0..3c49380 100644 --- a/components/el512/el512_display.h +++ b/components/el512/el512_display.h @@ -6,6 +6,7 @@ #include "el512_helpers.h" #include "konosuba.h" #include "steins.h" +#include "trek.h" #include "el512_panel.h" #include "esphome/components/display/display_buffer.h" #include "esphome/core/component.h" diff --git a/components/el512/el512_pages.h b/components/el512/el512_pages.h index 9fe2c56..4462c0b 100644 --- a/components/el512/el512_pages.h +++ b/components/el512/el512_pages.h @@ -33,6 +33,7 @@ struct HomeData { float grid{NAN}; // W, > 0 = import std::string agenda; // sensor.el512_agenda events: "day|time|title;..." std::string message; // the panel's Message text + std::string printer; // 3D printer state (Moonraker: printing, paused, error, ...) Reading rooms[4]; // Today's solar power in 15-minute slots (highest value seen per slot; -1 = diff --git a/components/el512/trek.cpp b/components/el512/trek.cpp new file mode 100644 index 0000000..70d3e86 --- /dev/null +++ b/components/el512/trek.cpp @@ -0,0 +1,429 @@ +#include "trek.h" + +#include +#include +#include + +#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 wrap(Display &it, display::BaseFont *font, const std::string &text, int width, + size_t max_lines) { + std::vector 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(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(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 diff --git a/components/el512/trek.h b/components/el512/trek.h new file mode 100644 index 0000000..2a7be4f --- /dev/null +++ b/components/el512/trek.h @@ -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 diff --git a/components/el512/trek_sprites.h b/components/el512/trek_sprites.h new file mode 100644 index 0000000..0920b91 --- /dev/null +++ b/components/el512/trek_sprites.h @@ -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 diff --git a/esphome/el512-panel.yaml b/esphome/el512-panel.yaml index 94dd5c9..f1e3380 100644 --- a/esphome/el512-panel.yaml +++ b/esphome/el512-panel.yaml @@ -10,6 +10,7 @@ substitutions: friendly_name: EL512 Panel weather: weather.knmi_home envoy: sensor.envoy_122225067403 + printer: sensor.mainsailos_current_print_state # Moonraker; "paused"/"error" = red alert esphome: 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(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(st_fonts) = {id(f_st_small), id(f_st_medium), id(f_st_large), id(f_ks_text), id(f_ks_pixel)}; esp32: board: wemos_d1_mini32 @@ -65,13 +67,15 @@ globals: - id: plex_updated type: uint32_t 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 type: el512::cyber::Fonts - id: ks_fonts type: el512::konosuba::Fonts - id: sg_fonts type: el512::steins::Fonts + - id: st_fonts + type: el512::trek::Fonts - id: cp_home type: el512::HomeData @@ -213,6 +217,20 @@ font: id: f_sg_jp size: 18 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 ------------------------------------------------- text_sensor: @@ -245,6 +263,9 @@ text_sensor: attribute: streams on_value: - lambda: id(plex_updated) = millis(); + - platform: homeassistant + id: printer_state + entity_id: ${printer} - platform: homeassistant id: plex_update entity_id: update.plex_media_server_mangavalkserver @@ -414,6 +435,10 @@ select: - D-Mail - Gadget Lab - World Lines + - LCARS Status + - Duty Roster + - Warp Core + - Captain's Log initial_option: Clock set_action: - lambda: |- @@ -438,6 +463,10 @@ select: else if (x == "D-Mail") p = id(page_sg_dmail); else if (x == "Gadget Lab") p = id(page_sg_lab); 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).update(); @@ -456,6 +485,13 @@ interval: - interval: 1s then: - 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(rotation_elapsed) < (int) id(page_interval).state) return; 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_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_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--) std::swap(order[i], order[random_uint32() % (i + 1)]); pos = order.size() - 1; // the next step starts at the front @@ -498,6 +534,7 @@ interval: d.solar_today = id(solar_today).state; d.agenda = id(agenda_events).state; d.message = id(panel_message).state; + d.printer = id(printer_state).state; d.house = id(house_power).state; d.grid = id(grid_import).state - id(grid_export).state; d.rooms[0] = {"Woonkamer", id(t_woonkamer).state}; @@ -517,7 +554,8 @@ display: # Redrawn every animation_interval (~15 fps) while shown. 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_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 on_page_change: - then: @@ -542,6 +580,10 @@ display: 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_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); pages: # 1. Clock, date and current weather @@ -797,3 +839,17 @@ display: - id: page_sg_world lambda: |- 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()); diff --git a/tools/trek_sprites.py b/tools/trek_sprites.py new file mode 100644 index 0000000..332fc67 --- /dev/null +++ b/tools/trek_sprites.py @@ -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__)