Icons: small images of their own size for pages
Bitmaps that only pages use (backgrounds, images by state) are now icons: own width and height, stored in /icons, not in the page lists or the Home Assistant dropdowns. Page backgrounds and image elements use icons or bitmaps; the renderer gets icons expanded at the top left, so pages look the same. Web UI: Icons tab with an editor, Make icon / Make bitmap, Save as icon on the Draw tab, icons in the backup. A one-time migration in time-boxed steps moves the uploaded bitmaps that only pages use, after the new firmware is marked good. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -149,6 +149,16 @@ See README.md for the overview.
|
|||||||
`VERSION` there (old records then fall back to defaults).
|
`VERSION` there (old records then fall back to defaults).
|
||||||
- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names
|
- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names
|
||||||
are restricted (`validBitmapName`), which also makes them safe in paths and JSON.
|
are restricted (`validBitmapName`), which also makes them safe in paths and JSON.
|
||||||
|
- Icons (`logic/icon_format.h`, `display/icon_store`, `/icons/<name>.bin`: width, height,
|
||||||
|
rows of (w+7)/8 bytes) are small images that pages use (Image elements, backgrounds at
|
||||||
|
the top left); they are never shown on their own and are not in the page lists or HA
|
||||||
|
dropdowns. Bitmaps, icons and pages share one namespace. `PageResources::background()`
|
||||||
|
tries bitmaps, then icons (expanded at 0,0), so the renderer doesn't know the difference.
|
||||||
|
`appMigrateIcons()` ran once (marker `/icons/.migrated`) after 2.10.0 was marked good.
|
||||||
|
- LittleFS is slow: listing a directory (`listBitmaps()`, `listPages()`, `storedNames()`)
|
||||||
|
takes about a second with 40 files. Never list per item in a loop. The first icon
|
||||||
|
migration did that, hit the 30 s watchdog and crash-looped into the recovery app; work
|
||||||
|
that touches many files runs in time-boxed steps over several `loop()` passes.
|
||||||
- The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`).
|
- The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`).
|
||||||
- Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a
|
- Dynamic pages (`logic/page.h`): background per time slot + elements, stored as a
|
||||||
versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage`
|
versioned binary struct (`display/page_store.cpp`, bump `VERSION` when `DynamicPage`
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 150 KiB |
@@ -57,7 +57,7 @@ Assistant's Plex and Tautulli integrations, no extra firmware:
|
|||||||
|
|
||||||
- `sensor.mangavalkserver` (Plex): how many are watching.
|
- `sensor.mangavalkserver` (Plex): how many are watching.
|
||||||
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon
|
- `sensor.{plex_user}_state` (Tautulli, per user): `playing`, `paused`, ... chooses the icon
|
||||||
(bitmaps `plex playing`, `plex paused`, `plex buffering`).
|
(icons `plex playing`, `plex paused`, `plex buffering`).
|
||||||
- `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user):
|
- `sensor.{plex_user}_full_title` and `sensor.{plex_user}_progress` (Tautulli, per user):
|
||||||
the title and the progress bar. Home Assistant creates these disabled: enable them on
|
the title and the progress bar. Home Assistant creates these disabled: enable them on
|
||||||
the Tautulli user's device in Home Assistant.
|
the Tautulli user's device in Home Assistant.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ headless mode).
|
|||||||
## Show
|
## Show
|
||||||
|
|
||||||
Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock:
|
Choose what the display shows during the day: a dynamic page (e.g. the built-in Clock:
|
||||||
Pikachu with the time) or any bitmap, and what is left on the dots when the display
|
Pikachu with the time) or any bitmap (not the [icons](#icons)), and what is left on the dots when the display
|
||||||
switches off. With a [schedule](#schedule), a page chosen here (or with the Home Assistant
|
switches off. With a [schedule](#schedule), a page chosen here (or with the Home Assistant
|
||||||
"Mode" dropdown) is shown until the next block starts; "Back to schedule" ends it earlier.
|
"Mode" dropdown) is shown until the next block starts; "Back to schedule" ends it earlier.
|
||||||
Bitmaps that pages or rules use are in their own subsection.
|
Bitmaps that pages or rules use are in their own subsection.
|
||||||
@@ -22,8 +22,8 @@ Bitmaps that pages or rules use are in their own subsection.
|
|||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
Dynamic pages: a background bitmap per time of day (e.g. Pikachu from 00:00, yawning
|
Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu
|
||||||
Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it
|
from 00:00, yawning Pikachu from 17:00) with up to 8 elements on top. The preview is drawn by the ESP32, so it
|
||||||
is exactly what the display shows; drag the elements on it, or select one and use the
|
is exactly what the display shows; drag the elements on it, or select one and use the
|
||||||
arrow keys. Built-in pages can be copied, not changed. See the [pictures of the pages](pages.md).
|
arrow keys. Built-in pages can be copied, not changed. See the [pictures of the pages](pages.md).
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ arrow keys. Built-in pages can be copied, not changed. See the [pictures of the
|
|||||||
| Text | Up to 32 characters |
|
| Text | Up to 32 characters |
|
||||||
| Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed |
|
| Countdown | To a date and time: smart (`12 dagen`, `5 uur`, `34 min`, `0:45`), days, `12d 04:05`, with seconds; a text when it has passed |
|
||||||
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
|
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
|
||||||
| Image | A bitmap at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the bitmap `weer cloudy`) |
|
| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) |
|
||||||
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame |
|
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame |
|
||||||
|
|
||||||
Every element has a position, alignment, font and overlay (on top, box that clears the
|
Every element has a position, alignment, font and overlay (on top, box that clears the
|
||||||
@@ -71,16 +71,33 @@ Start, stop and reset the stopwatch that pages can show (also in Home Assistant)
|
|||||||
|
|
||||||
All bitmaps with a preview: show one as the page, send it live, open it in the editor,
|
All bitmaps with a preview: show one as the page, send it live, open it in the editor,
|
||||||
export or delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be
|
export or delete it. The built-in ones (Nintendo, Pikachu, Pikachu yawning) can't be
|
||||||
deleted, nor can a bitmap that a page, the schedule or a rule uses.
|
deleted, nor can a bitmap that a page, the schedule or a rule uses. "Make icon" turns a
|
||||||
|
bitmap that is only part of pages into an [icon](#icons).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Small images (1 x 1 up to 84 x 19 dots, each its own size) for dynamic pages: an Image
|
||||||
|
element, or a background (drawn at the top left). Icons are never shown on their own, so
|
||||||
|
they are not in the page lists (Show, Schedule, rules, Home Assistant "Mode"). Each icon
|
||||||
|
shows its size and the pages that use it. The editor: draw dots, change the size (the dots
|
||||||
|
stay at the top left), crop to the dots, clear, invert, load an image (scaled to the icon
|
||||||
|
size), show it on the display. "Make bitmap" turns an icon into a full screen bitmap.
|
||||||
|
|
||||||
|
Firmware 2.10.0 made icons of all uploaded bitmaps that only pages used (backgrounds and
|
||||||
|
images, not shown by the schedule, a rule, the chosen page or the off image), once, after
|
||||||
|
the new firmware was marked as good. They keep their names and land on the same dots, so
|
||||||
|
the pages didn't change.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Draw / Live
|
## Draw / Live
|
||||||
|
|
||||||
Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the
|
Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the
|
||||||
browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends
|
browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends
|
||||||
it to the display right away (*live mode*); with "live while drawing" every change appears
|
it to the display right away (*live mode*); with "live while drawing" every change appears
|
||||||
on the dots. Save it as a bitmap with a name.
|
on the dots. Save it as a bitmap with a name, or as an icon (only the area with dots).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -115,11 +132,12 @@ few seconds). `loop()` never sleeps, so a CPU percentage would always say 100 %.
|
|||||||
how often the image was repaired, Nano restarts and `ERR` replies (with the last one).
|
how often the image was repaired, Nano restarts and `ERR` replies (with the last one).
|
||||||
**Memory:** free heap now and the lowest since boot.
|
**Memory:** free heap now and the lowest since boot.
|
||||||
|
|
||||||
**Backup:** export everything (settings, the schedule and rules, your bitmaps, pages and
|
**Backup:** export everything (settings, the schedule and rules, your bitmaps, icons,
|
||||||
fonts; the MQTT password and HA token only when ticked) to one JSON file, and import a
|
pages and fonts; the MQTT password and HA token only when ticked) to one JSON file, and
|
||||||
file: choose which items and which parts of the settings. Imported items are added (a
|
import a file: choose which items and which parts of the settings. Imported items are
|
||||||
taken name gets a number) unless you give them an existing name. Pages and bitmaps also
|
added (a taken name gets a number) unless you give them an existing name. Pages, bitmaps
|
||||||
have their own Export button, e.g. to share one page (with the bitmaps and fonts it uses).
|
and icons also have their own Export button, e.g. to share one page (with the bitmaps,
|
||||||
|
icons and fonts it uses).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -129,8 +147,8 @@ have their own Export button, e.g. to share one page (with the bitmaps and fonts
|
|||||||
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
|
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
|
||||||
switch still wins: when it is off, nothing is shown.
|
switch still wins: when it is off, nothing is shown.
|
||||||
|
|
||||||
**Bitmaps, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB, room for well over
|
**Bitmaps, icons, pages and fonts** are stored on the ESP32 (LittleFS, 320 KB; up to 200
|
||||||
a thousand bitmaps, 50 pages, 20 fonts). Pages and bitmaps share their names. They need
|
bitmaps, 200 icons, 50 pages, 20 fonts). Pages, bitmaps and icons share their names. They need
|
||||||
the new flash layout from the [one-time installation](recovery.md#one-time-installation-usb);
|
the new flash layout from the [one-time installation](recovery.md#one-time-installation-usb);
|
||||||
before that, only the built-in ones are available.
|
before that, only the built-in ones are available.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
|
|||||||
| `logic/settings.*` | The settings of the web UI with their defaults and checks |
|
| `logic/settings.*` | The settings of the web UI with their defaults and checks |
|
||||||
| `logic/schedule.*` | When the display is on (night, clock, standby), from the settings |
|
| `logic/schedule.*` | When the display is on (night, clock, standby), from the settings |
|
||||||
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts |
|
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts |
|
||||||
|
| `logic/icon_format.*` | Icons: small bitmaps of their own size, to and from full screen bitmaps |
|
||||||
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) |
|
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) |
|
||||||
| `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) |
|
| `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) |
|
||||||
| `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing |
|
| `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing |
|
||||||
@@ -40,6 +41,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
|
|||||||
| **`display/`** | **What is drawn** |
|
| **`display/`** | **What is drawn** |
|
||||||
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
|
| `display/frames.*` | The screens: dynamic page, "No WiFi", page bitmap, live image, off image (`FlipdotFrames`) |
|
||||||
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
||||||
|
| `display/icon_store.*` | Icons (small images for pages) on LittleFS |
|
||||||
| `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
|
| `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
|
||||||
| `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) |
|
| `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) |
|
||||||
| `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values |
|
| `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "display/bitmap_store.h"
|
#include "display/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
|
#include "display/icon_store.h"
|
||||||
#include "display/page_resources.h"
|
#include "display/page_resources.h"
|
||||||
#include "display/page_store.h"
|
#include "display/page_store.h"
|
||||||
#include "logic/page_selector.h"
|
#include "logic/page_selector.h"
|
||||||
@@ -274,6 +275,8 @@ const char *appSaveBitmap(const char *name, const uint8_t *bitmap)
|
|||||||
{
|
{
|
||||||
if (pageExists(name))
|
if (pageExists(name))
|
||||||
return "A page has this name";
|
return "A page has this name";
|
||||||
|
if (iconExists(name))
|
||||||
|
return "An icon has this name";
|
||||||
const char *error = saveBitmap(name, bitmap);
|
const char *error = saveBitmap(name, bitmap);
|
||||||
if (error)
|
if (error)
|
||||||
return error;
|
return error;
|
||||||
@@ -297,7 +300,7 @@ const char *appDeleteBitmap(const char *name)
|
|||||||
{
|
{
|
||||||
if (scheduleUses(page_schedule, name))
|
if (scheduleUses(page_schedule, name))
|
||||||
return USED_BY_SCHEDULE;
|
return USED_BY_SCHEDULE;
|
||||||
String user = pageUsingBitmap(name);
|
String user = pageUsingImage(name);
|
||||||
if (user.length())
|
if (user.length())
|
||||||
return usedBy(user);
|
return usedBy(user);
|
||||||
const char *error = deleteBitmap(name);
|
const char *error = deleteBitmap(name);
|
||||||
@@ -314,9 +317,11 @@ const char *appSavePage(const char *name, const DynamicPage &page)
|
|||||||
{
|
{
|
||||||
if (bitmapExists(name))
|
if (bitmapExists(name))
|
||||||
return "A bitmap has this name";
|
return "A bitmap has this name";
|
||||||
|
if (iconExists(name))
|
||||||
|
return "An icon has this name";
|
||||||
for (int i = 0; i < page.slot_count; i++)
|
for (int i = 0; i < page.slot_count; i++)
|
||||||
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap))
|
if (page.slots[i].bitmap[0] && !bitmapExists(page.slots[i].bitmap) && !iconExists(page.slots[i].bitmap))
|
||||||
return "Background bitmap not found";
|
return "Background bitmap or icon not found";
|
||||||
for (int i = 0; i < page.element_count; i++)
|
for (int i = 0; i < page.element_count; i++)
|
||||||
if (!fontExists(page.elements[i].font))
|
if (!fontExists(page.elements[i].font))
|
||||||
return "Font not found";
|
return "Font not found";
|
||||||
@@ -340,6 +345,183 @@ const char *appDeletePage(const char *name)
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char *appSaveIcon(const char *name, const Icon &icon)
|
||||||
|
{
|
||||||
|
if (pageExists(name))
|
||||||
|
return "A page has this name";
|
||||||
|
if (bitmapExists(name))
|
||||||
|
return "A bitmap has this name";
|
||||||
|
const char *error = saveIcon(name, icon);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
reloadPage(); // the page may use it
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *appDeleteIcon(const char *name)
|
||||||
|
{
|
||||||
|
String user = pageUsingImage(name);
|
||||||
|
if (user.length())
|
||||||
|
return usedBy(user);
|
||||||
|
const char *error = deleteIcon(name);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
reloadPage(); // an image by state may have shown it
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why the bitmap can't become an icon (it is shown on its own), or nullptr
|
||||||
|
static const char *shownOnItsOwn(const char *name)
|
||||||
|
{
|
||||||
|
if (builtinBitmap(name))
|
||||||
|
return "Built-in bitmaps can't become icons";
|
||||||
|
if (scheduleUses(page_schedule, name))
|
||||||
|
return "Shown by the schedule or a rule: change that first";
|
||||||
|
if (sameName(settings.page, name))
|
||||||
|
return "It is the chosen page: choose another one first";
|
||||||
|
if (sameName(settings.off_page, name))
|
||||||
|
return "It is the off image: choose another one first";
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without redrawing: the pages look the same
|
||||||
|
static const char *moveToIcon(const char *name)
|
||||||
|
{
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
if (!loadBitmap(name, bitmap))
|
||||||
|
return "Bitmap not found";
|
||||||
|
Icon icon;
|
||||||
|
iconFromBitmap(bitmap, icon);
|
||||||
|
const char *error = saveIcon(name, icon);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
error = deleteBitmap(name);
|
||||||
|
if (error)
|
||||||
|
deleteIcon(name); // only one of them
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *appBitmapToIcon(const char *name)
|
||||||
|
{
|
||||||
|
const char *error = shownOnItsOwn(name);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
error = moveToIcon(name);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
reloadPage();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *appIconToBitmap(const char *name)
|
||||||
|
{
|
||||||
|
Icon icon;
|
||||||
|
if (!loadIcon(name, icon))
|
||||||
|
return "Icon not found";
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
iconToBitmap(icon, bitmap);
|
||||||
|
const char *error = saveBitmap(name, bitmap);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
error = deleteIcon(name);
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
deleteBitmap(name); // only one of them
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
reloadPage();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-time move, in steps over many loop() passes: LittleFS is slow (listing a
|
||||||
|
// directory of 40 files takes about a second), and loop() must never get near the
|
||||||
|
// watchdog. Each pass works for about MIGRATION_STEP_MS.
|
||||||
|
enum class Migration
|
||||||
|
{
|
||||||
|
Start,
|
||||||
|
Pages, // which candidates the pages use (one page after the other)
|
||||||
|
Move,
|
||||||
|
Done,
|
||||||
|
};
|
||||||
|
static const uint32_t MIGRATION_STEP_MS = 1000;
|
||||||
|
static Migration migration = Migration::Start;
|
||||||
|
static std::vector<String> migration_bitmaps; // uploaded bitmaps not shown on their own
|
||||||
|
static std::vector<bool> migration_used;
|
||||||
|
static std::vector<String> migration_pages;
|
||||||
|
static size_t migration_next = 0;
|
||||||
|
static int migration_moved = 0;
|
||||||
|
static uint32_t migration_started = 0;
|
||||||
|
|
||||||
|
void appMigrateIcons()
|
||||||
|
{
|
||||||
|
if (migration == Migration::Done)
|
||||||
|
return;
|
||||||
|
uint32_t pass = millis();
|
||||||
|
auto time_left = [pass]() { return millis() - pass < MIGRATION_STEP_MS; };
|
||||||
|
|
||||||
|
if (migration == Migration::Start)
|
||||||
|
{
|
||||||
|
if (iconMigrationDone())
|
||||||
|
{
|
||||||
|
migration = Migration::Done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
migration_started = pass;
|
||||||
|
for (const BitmapInfo &b : listBitmaps())
|
||||||
|
if (!b.builtin && !shownOnItsOwn(b.name.c_str()))
|
||||||
|
migration_bitmaps.push_back(b.name);
|
||||||
|
migration_used.assign(migration_bitmaps.size(), false);
|
||||||
|
for (const PageInfo &p : listPages())
|
||||||
|
migration_pages.push_back(p.name);
|
||||||
|
migration_next = 0;
|
||||||
|
migration = Migration::Pages;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migration == Migration::Pages)
|
||||||
|
{
|
||||||
|
DynamicPage *page = new DynamicPage;
|
||||||
|
while (migration_next < migration_pages.size() && time_left())
|
||||||
|
if (loadPage(migration_pages[migration_next++].c_str(), *page))
|
||||||
|
for (size_t i = 0; i < migration_bitmaps.size(); i++)
|
||||||
|
if (pageUsesImage(*page, migration_bitmaps[i].c_str(), true))
|
||||||
|
migration_used[i] = true;
|
||||||
|
delete page;
|
||||||
|
if (migration_next >= migration_pages.size())
|
||||||
|
{
|
||||||
|
migration_next = 0;
|
||||||
|
migration = Migration::Move;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (migration_next < migration_bitmaps.size() && time_left())
|
||||||
|
{
|
||||||
|
size_t i = migration_next++;
|
||||||
|
const char *name = migration_bitmaps[i].c_str();
|
||||||
|
// Checked again: the web UI may have changed something in the meantime
|
||||||
|
if (!migration_used[i] || shownOnItsOwn(name))
|
||||||
|
continue;
|
||||||
|
const char *error = moveToIcon(name);
|
||||||
|
if (error)
|
||||||
|
Serial.printf("Bitmap \"%s\" stays a bitmap: %s\n", name, error);
|
||||||
|
else
|
||||||
|
migration_moved++;
|
||||||
|
}
|
||||||
|
if (migration_next < migration_bitmaps.size())
|
||||||
|
return;
|
||||||
|
|
||||||
|
setIconMigrationDone();
|
||||||
|
Serial.printf("%d bitmaps that only pages use became icons (%lu ms)\n", migration_moved,
|
||||||
|
(unsigned long)(millis() - migration_started));
|
||||||
|
migration_bitmaps.clear();
|
||||||
|
migration_used.clear();
|
||||||
|
migration_pages.clear();
|
||||||
|
migration = Migration::Done;
|
||||||
|
if (migration_moved)
|
||||||
|
reloadPage();
|
||||||
|
}
|
||||||
|
|
||||||
const char *appSaveFont(const char *name, const uint8_t *data, size_t size)
|
const char *appSaveFont(const char *name, const uint8_t *data, size_t size)
|
||||||
{
|
{
|
||||||
const char *error = saveFont(name, data, size);
|
const char *error = saveFont(name, data, size);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "display/frames.h"
|
#include "display/frames.h"
|
||||||
#include "display/status_screen.h"
|
#include "display/status_screen.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
|
#include "logic/icon_format.h"
|
||||||
#include "logic/local_time.h"
|
#include "logic/local_time.h"
|
||||||
#include "logic/loop_stats.h"
|
#include "logic/loop_stats.h"
|
||||||
#include "logic/page.h"
|
#include "logic/page.h"
|
||||||
@@ -48,11 +49,20 @@ void appPageLoop(bool time_valid, const LocalTime &time);
|
|||||||
// Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
|
// Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
|
||||||
const char *appSelectOffPage(const char *name);
|
const char *appSelectOffPage(const char *name);
|
||||||
|
|
||||||
// Bitmaps, dynamic pages and fonts: stored, and the display redrawn when it shows them.
|
// Bitmaps, icons, dynamic pages and fonts: stored, and the display redrawn when it shows
|
||||||
// Bitmaps and pages share one namespace; what a page uses can't be deleted. All return
|
// them. Bitmaps, icons and pages share one namespace; what a page uses can't be deleted.
|
||||||
// nullptr or an error message.
|
// All return nullptr or an error message.
|
||||||
const char *appSaveBitmap(const char *name, const uint8_t *bitmap);
|
const char *appSaveBitmap(const char *name, const uint8_t *bitmap);
|
||||||
const char *appDeleteBitmap(const char *name);
|
const char *appDeleteBitmap(const char *name);
|
||||||
|
const char *appSaveIcon(const char *name, const Icon &icon);
|
||||||
|
const char *appDeleteIcon(const char *name);
|
||||||
|
// A bitmap becomes an icon (cut to its dots from the top left, so pages look the same) and
|
||||||
|
// the other way around; not for bitmaps that are shown on their own
|
||||||
|
const char *appBitmapToIcon(const char *name);
|
||||||
|
const char *appIconToBitmap(const char *name);
|
||||||
|
// Once: the uploaded bitmaps that only pages use become icons. Call from loop(); returns at
|
||||||
|
// once when it is done.
|
||||||
|
void appMigrateIcons();
|
||||||
const char *appSavePage(const char *name, const DynamicPage &page);
|
const char *appSavePage(const char *name, const DynamicPage &page);
|
||||||
const char *appDeletePage(const char *name);
|
const char *appDeletePage(const char *name);
|
||||||
const char *appSaveFont(const char *name, const uint8_t *data, size_t size);
|
const char *appSaveFont(const char *name, const uint8_t *data, size_t size);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
|
|||||||
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
||||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
||||||
|
|
||||||
#define FIRMWARE_VERSION "2.9.0"
|
#define FIRMWARE_VERSION "2.10.0"
|
||||||
|
|
||||||
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
||||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ static String path(const char *name)
|
|||||||
bool bitmapStoreBegin()
|
bool bitmapStoreBegin()
|
||||||
{
|
{
|
||||||
mounted = LittleFS.begin(true);
|
mounted = LittleFS.begin(true);
|
||||||
for (const char *dir : {DIR, "/pages", "/fonts"})
|
for (const char *dir : {DIR, "/pages", "/fonts", "/icons"})
|
||||||
if (mounted && !LittleFS.exists(dir))
|
if (mounted && !LittleFS.exists(dir))
|
||||||
LittleFS.mkdir(dir);
|
LittleFS.mkdir(dir);
|
||||||
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
|
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
|
||||||
@@ -108,6 +108,11 @@ uint32_t bitmapListRevision()
|
|||||||
return revision;
|
return revision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool builtinBitmap(const char *name)
|
||||||
|
{
|
||||||
|
return builtin(name) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool bitmapExists(const char *name)
|
bool bitmapExists(const char *name)
|
||||||
{
|
{
|
||||||
if (builtin(name))
|
if (builtin(name))
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ std::vector<BitmapInfo> listBitmaps();
|
|||||||
// Changes each time a bitmap is saved or deleted
|
// Changes each time a bitmap is saved or deleted
|
||||||
uint32_t bitmapListRevision();
|
uint32_t bitmapListRevision();
|
||||||
bool bitmapExists(const char *name);
|
bool bitmapExists(const char *name);
|
||||||
|
// Without reading the storage
|
||||||
|
bool builtinBitmap(const char *name);
|
||||||
bool loadBitmap(const char *name, uint8_t *bitmap);
|
bool loadBitmap(const char *name, uint8_t *bitmap);
|
||||||
|
|
||||||
// Return nullptr on success, otherwise a message for the user
|
// Return nullptr on success, otherwise a message for the user
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#include "display/icon_store.h"
|
||||||
|
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <map>
|
||||||
|
#include <strings.h>
|
||||||
|
|
||||||
|
#include "display/bitmap_store.h"
|
||||||
|
|
||||||
|
static const char *DIR = "/icons";
|
||||||
|
// Written when the bitmaps were moved (not a valid name, so never listed)
|
||||||
|
static const char *MIGRATED = "/icons/.migrated";
|
||||||
|
|
||||||
|
static uint32_t revision = 0;
|
||||||
|
// Counted once (listing the directory is slow), then kept up to date; -1: not yet
|
||||||
|
static int count = -1;
|
||||||
|
|
||||||
|
// Like the bitmaps (display/bitmap_store.cpp): images try a name per state, and pages are
|
||||||
|
// drawn for every frame and preview, so icons and missing names are kept in RAM
|
||||||
|
struct CachedIcon
|
||||||
|
{
|
||||||
|
bool exists;
|
||||||
|
Icon icon;
|
||||||
|
};
|
||||||
|
static std::map<String, CachedIcon> cache;
|
||||||
|
static const size_t CACHE_MAX = 64;
|
||||||
|
|
||||||
|
static String path(const char *name)
|
||||||
|
{
|
||||||
|
return String(DIR) + "/" + name + ".bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ensureDir()
|
||||||
|
{
|
||||||
|
if (!bitmapStorageAvailable())
|
||||||
|
return false;
|
||||||
|
return LittleFS.exists(DIR) || LittleFS.mkdir(DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> listIcons()
|
||||||
|
{
|
||||||
|
std::vector<String> names = storedNames(DIR, ".bin");
|
||||||
|
std::sort(names.begin(), names.end(),
|
||||||
|
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t iconListRevision()
|
||||||
|
{
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool iconExists(const char *name)
|
||||||
|
{
|
||||||
|
Icon icon;
|
||||||
|
return loadIcon(name, icon); // through the cache
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadIcon(const char *name, Icon &icon)
|
||||||
|
{
|
||||||
|
if (!bitmapStorageAvailable() || !validBitmapName(name))
|
||||||
|
return false;
|
||||||
|
auto cached = cache.find(name);
|
||||||
|
if (cached != cache.end())
|
||||||
|
{
|
||||||
|
if (cached->second.exists)
|
||||||
|
icon = cached->second.icon;
|
||||||
|
return cached->second.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked first: open() logs an error for a missing file
|
||||||
|
bool ok = false;
|
||||||
|
Icon loaded;
|
||||||
|
if (LittleFS.exists(path(name)))
|
||||||
|
{
|
||||||
|
File file = LittleFS.open(path(name), "r");
|
||||||
|
if (file)
|
||||||
|
{
|
||||||
|
uint8_t size[2];
|
||||||
|
ok = file.read(size, 2) == 2 && validIconSize(size[0], size[1]);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
loaded.width = size[0];
|
||||||
|
loaded.height = size[1];
|
||||||
|
size_t bytes = iconBytes(loaded.width, loaded.height);
|
||||||
|
ok = file.read(loaded.data, bytes) == bytes;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cache.size() >= CACHE_MAX)
|
||||||
|
cache.clear();
|
||||||
|
CachedIcon &entry = cache[name];
|
||||||
|
entry.exists = ok;
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
entry.icon = loaded;
|
||||||
|
icon = loaded;
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *saveIcon(const char *name, const Icon &icon)
|
||||||
|
{
|
||||||
|
if (!validBitmapName(name))
|
||||||
|
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
|
||||||
|
if (!validIconSize(icon.width, icon.height))
|
||||||
|
return "Icons are 1 x 1 to 84 x 19 dots";
|
||||||
|
if (!ensureDir())
|
||||||
|
return "No storage (install the new flash layout over USB, see README)";
|
||||||
|
bool is_new = !LittleFS.exists(path(name));
|
||||||
|
if (count < 0)
|
||||||
|
count = storedNames(DIR, ".bin").size();
|
||||||
|
if (is_new && count >= MAX_ICONS)
|
||||||
|
return "Too many icons, delete some first";
|
||||||
|
|
||||||
|
File file = LittleFS.open(path(name), "w");
|
||||||
|
if (!file)
|
||||||
|
return "Could not write the file";
|
||||||
|
uint8_t size[2] = {icon.width, icon.height};
|
||||||
|
size_t bytes = iconBytes(icon.width, icon.height);
|
||||||
|
bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes;
|
||||||
|
file.close();
|
||||||
|
revision++;
|
||||||
|
cache.clear();
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
LittleFS.remove(path(name)); // not half an icon
|
||||||
|
count = -1;
|
||||||
|
return "Storage full";
|
||||||
|
}
|
||||||
|
if (is_new)
|
||||||
|
count++;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *deleteIcon(const char *name)
|
||||||
|
{
|
||||||
|
if (!bitmapStorageAvailable() || !validBitmapName(name) || !LittleFS.exists(path(name)))
|
||||||
|
return "Icon not found";
|
||||||
|
if (!LittleFS.remove(path(name)))
|
||||||
|
return "Could not delete the file";
|
||||||
|
if (count > 0)
|
||||||
|
count--;
|
||||||
|
revision++;
|
||||||
|
cache.clear();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool iconMigrationDone()
|
||||||
|
{
|
||||||
|
return !bitmapStorageAvailable() || LittleFS.exists(MIGRATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIconMigrationDone()
|
||||||
|
{
|
||||||
|
if (!ensureDir())
|
||||||
|
return;
|
||||||
|
File file = LittleFS.open(MIGRATED, "w");
|
||||||
|
if (file)
|
||||||
|
{
|
||||||
|
file.print("1");
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "logic/icon_format.h"
|
||||||
|
|
||||||
|
// Icons (logic/icon_format.h): small images that pages use as images or backgrounds, never
|
||||||
|
// shown on their own. Stored on LittleFS (mounted by display/bitmap_store) as
|
||||||
|
// /icons/<name>.bin: width, height, then the rows. Icons, bitmaps and pages share one
|
||||||
|
// namespace: app.cpp checks that a new name is not taken.
|
||||||
|
|
||||||
|
const int MAX_ICONS = 200;
|
||||||
|
|
||||||
|
// Sorted without case
|
||||||
|
std::vector<String> listIcons();
|
||||||
|
// Changes each time an icon is saved or deleted
|
||||||
|
uint32_t iconListRevision();
|
||||||
|
bool iconExists(const char *name);
|
||||||
|
bool loadIcon(const char *name, Icon &icon);
|
||||||
|
|
||||||
|
// Return nullptr on success, otherwise a message for the user
|
||||||
|
const char *saveIcon(const char *name, const Icon &icon);
|
||||||
|
const char *deleteIcon(const char *name);
|
||||||
|
|
||||||
|
// The one-time move of the bitmaps that only pages use (app.cpp) is done
|
||||||
|
bool iconMigrationDone();
|
||||||
|
void setIconMigrationDone();
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include "display/bitmap_store.h"
|
#include "display/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
|
#include "display/icon_store.h"
|
||||||
#include "net/ha_api.h"
|
#include "net/ha_api.h"
|
||||||
|
|
||||||
Stopwatch stopwatch;
|
Stopwatch stopwatch;
|
||||||
@@ -11,7 +12,13 @@ StoredPageResources pageResources(stopwatch);
|
|||||||
|
|
||||||
bool StoredPageResources::background(const char *name, uint8_t *bitmap)
|
bool StoredPageResources::background(const char *name, uint8_t *bitmap)
|
||||||
{
|
{
|
||||||
return loadBitmap(name, bitmap);
|
if (loadBitmap(name, bitmap))
|
||||||
|
return true;
|
||||||
|
Icon icon;
|
||||||
|
if (!loadIcon(name, icon))
|
||||||
|
return false;
|
||||||
|
iconToBitmap(icon, bitmap); // at the top left: images are drawn from their x, y
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Font StoredPageResources::font(const char *name)
|
Font StoredPageResources::font(const char *name)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#include "logic/page_render.h"
|
#include "logic/page_render.h"
|
||||||
#include "logic/stopwatch.h"
|
#include "logic/stopwatch.h"
|
||||||
|
|
||||||
// Bitmaps from display/bitmap_store, fonts from display/font_store, Home Assistant values
|
// Bitmaps and icons from display/bitmap_store and display/icon_store, fonts from display/font_store, Home Assistant values
|
||||||
// from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
|
// from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
|
||||||
// web UI
|
// web UI
|
||||||
class StoredPageResources : public PageResources
|
class StoredPageResources : public PageResources
|
||||||
|
|||||||
@@ -458,14 +458,9 @@ template <typename F> static String findPage(F uses)
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
String pageUsingBitmap(const char *bitmap)
|
String pageUsingImage(const char *name)
|
||||||
{
|
{
|
||||||
return findPage([bitmap](const DynamicPage &page) {
|
return findPage([name](const DynamicPage &page) { return pageUsesImage(page, name); });
|
||||||
for (int i = 0; i < page.slot_count; i++)
|
|
||||||
if (sameName(page.slots[i].bitmap, bitmap))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String pageUsingFont(const char *font)
|
String pageUsingFont(const char *font)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ bool loadPage(const char *name, DynamicPage &page);
|
|||||||
const char *savePage(const char *name, const DynamicPage &page);
|
const char *savePage(const char *name, const DynamicPage &page);
|
||||||
const char *deletePage(const char *name);
|
const char *deletePage(const char *name);
|
||||||
|
|
||||||
// The first stored page that uses the bitmap (as background) or the font; empty if none
|
// The first stored page that uses the bitmap or icon (logic/page.h pageUsesImage) or the
|
||||||
String pageUsingBitmap(const char *bitmap);
|
// font; empty if none
|
||||||
|
String pageUsingImage(const char *name);
|
||||||
String pageUsingFont(const char *font);
|
String pageUsingFont(const char *font);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#include "logic/icon_format.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
bool validIconSize(int width, int height)
|
||||||
|
{
|
||||||
|
return width >= 1 && height >= 1 && width <= ICON_MAX_WIDTH && height <= ICON_MAX_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool iconPixel(const Icon &icon, int x, int y)
|
||||||
|
{
|
||||||
|
if (x < 0 || y < 0 || x >= icon.width || y >= icon.height)
|
||||||
|
return false;
|
||||||
|
return icon.data[y * iconRowBytes(icon.width) + x / 8] & (0x80 >> (x & 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIconPixel(Icon &icon, int x, int y, bool on)
|
||||||
|
{
|
||||||
|
if (x < 0 || y < 0 || x >= icon.width || y >= icon.height)
|
||||||
|
return;
|
||||||
|
uint8_t &byte = icon.data[y * iconRowBytes(icon.width) + x / 8];
|
||||||
|
if (on)
|
||||||
|
byte |= 0x80 >> (x & 7);
|
||||||
|
else
|
||||||
|
byte &= ~(0x80 >> (x & 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int hexValue(char c)
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f')
|
||||||
|
return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F')
|
||||||
|
return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool iconFromHex(int width, int height, const char *hex, Icon &icon)
|
||||||
|
{
|
||||||
|
if (!validIconSize(width, height) || hex == nullptr)
|
||||||
|
return false;
|
||||||
|
int bytes = iconBytes(width, height);
|
||||||
|
if (strlen(hex) != (size_t)bytes * 2)
|
||||||
|
return false;
|
||||||
|
Icon result;
|
||||||
|
result.width = width;
|
||||||
|
result.height = height;
|
||||||
|
for (int i = 0; i < bytes; i++)
|
||||||
|
{
|
||||||
|
int high = hexValue(hex[2 * i]);
|
||||||
|
int low = hexValue(hex[2 * i + 1]);
|
||||||
|
if (high < 0 || low < 0)
|
||||||
|
return false;
|
||||||
|
result.data[i] = (high << 4) | low;
|
||||||
|
}
|
||||||
|
// The unused bits at the end of each row stay off
|
||||||
|
for (int y = 0; y < height; y++)
|
||||||
|
for (int x = width; x < iconRowBytes(width) * 8; x++)
|
||||||
|
result.data[y * iconRowBytes(width) + x / 8] &= ~(0x80 >> (x & 7));
|
||||||
|
icon = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void iconToHex(const Icon &icon, char *out)
|
||||||
|
{
|
||||||
|
static const char digits[] = "0123456789abcdef";
|
||||||
|
int bytes = iconBytes(icon.width, icon.height);
|
||||||
|
for (int i = 0; i < bytes; i++)
|
||||||
|
{
|
||||||
|
out[2 * i] = digits[icon.data[i] >> 4];
|
||||||
|
out[2 * i + 1] = digits[icon.data[i] & 0x0F];
|
||||||
|
}
|
||||||
|
out[bytes * 2] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void iconToBitmap(const Icon &icon, uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
memset(bitmap, 0, BITMAP_BYTES);
|
||||||
|
for (int y = 0; y < icon.height; y++)
|
||||||
|
for (int x = 0; x < icon.width; x++)
|
||||||
|
if (iconPixel(icon, x, y))
|
||||||
|
setBitmapPixel(bitmap, x, y, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void iconFromBitmap(const uint8_t *bitmap, Icon &icon)
|
||||||
|
{
|
||||||
|
int right = 0, bottom = 0;
|
||||||
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||||
|
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||||
|
if (bitmapPixel(bitmap, x, y))
|
||||||
|
{
|
||||||
|
right = x > right ? x : right;
|
||||||
|
bottom = y > bottom ? y : bottom;
|
||||||
|
}
|
||||||
|
icon = Icon();
|
||||||
|
icon.width = right + 1;
|
||||||
|
icon.height = bottom + 1;
|
||||||
|
for (int y = 0; y < icon.height; y++)
|
||||||
|
for (int x = 0; x < icon.width; x++)
|
||||||
|
if (bitmapPixel(bitmap, x, y))
|
||||||
|
setIconPixel(icon, x, y, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
|
|
||||||
|
// Icons: small bitmaps of their own size (1 x 1 up to 84 x 19) that pages place as images
|
||||||
|
// or backgrounds; they are never shown on their own. Rows from top to bottom, each
|
||||||
|
// (width + 7) / 8 bytes, the most significant bit is the leftmost dot (like the full
|
||||||
|
// screen bitmaps of logic/bitmap_format.h). Icons share the names of bitmaps and pages.
|
||||||
|
// Pure logic, tested in test/test_esp32.
|
||||||
|
|
||||||
|
const int ICON_MAX_WIDTH = BITMAP_WIDTH;
|
||||||
|
const int ICON_MAX_HEIGHT = BITMAP_HEIGHT;
|
||||||
|
|
||||||
|
struct Icon
|
||||||
|
{
|
||||||
|
uint8_t width = 0;
|
||||||
|
uint8_t height = 0;
|
||||||
|
uint8_t data[BITMAP_BYTES] = {}; // iconBytes(width, height) are used
|
||||||
|
};
|
||||||
|
|
||||||
|
inline int iconRowBytes(int width)
|
||||||
|
{
|
||||||
|
return (width + 7) / 8;
|
||||||
|
}
|
||||||
|
inline int iconBytes(int width, int height)
|
||||||
|
{
|
||||||
|
return iconRowBytes(width) * height;
|
||||||
|
}
|
||||||
|
bool validIconSize(int width, int height);
|
||||||
|
|
||||||
|
bool iconPixel(const Icon &icon, int x, int y);
|
||||||
|
void setIconPixel(Icon &icon, int x, int y, bool on);
|
||||||
|
|
||||||
|
// Exactly iconBytes(width, height) * 2 hex digits. Returns false on anything else.
|
||||||
|
bool iconFromHex(int width, int height, const char *hex, Icon &icon);
|
||||||
|
// out must hold BITMAP_HEX_LENGTH + 1 characters
|
||||||
|
void iconToHex(const Icon &icon, char *out);
|
||||||
|
|
||||||
|
// The icon in a full screen bitmap, with its top left corner at 0, 0
|
||||||
|
void iconToBitmap(const Icon &icon, uint8_t *bitmap);
|
||||||
|
// A full screen bitmap as an icon that reaches from 0, 0 to its last dot on the right and
|
||||||
|
// at the bottom, so it is drawn on the same dots (1 x 1 when it has no dots)
|
||||||
|
void iconFromBitmap(const uint8_t *bitmap, Icon &icon);
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <strings.h>
|
||||||
|
|
||||||
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
||||||
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
|
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
|
||||||
@@ -70,6 +71,31 @@ bool elementUsesHa(const PageElement &element)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < page.slot_count; i++)
|
||||||
|
if (sameName(page.slots[i].bitmap, name))
|
||||||
|
return true;
|
||||||
|
for (int i = 0; i < page.element_count; i++)
|
||||||
|
{
|
||||||
|
const PageElement &e = page.elements[i];
|
||||||
|
if (e.type != ElementType::Image)
|
||||||
|
continue;
|
||||||
|
if (!e.entity[0])
|
||||||
|
{
|
||||||
|
if (sameName(e.text, name))
|
||||||
|
return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
size_t start = strlen(e.text);
|
||||||
|
if (strncasecmp(name, e.text, start) != 0)
|
||||||
|
continue;
|
||||||
|
if (by_state ? name[start] != '\0' : strcasecmp(name + start, "default") == 0)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
bool validEntityId(const char *entity)
|
bool validEntityId(const char *entity)
|
||||||
{
|
{
|
||||||
if (entity == nullptr)
|
if (entity == nullptr)
|
||||||
|
|||||||
@@ -223,6 +223,11 @@ void plexUserSlug(const char *name, char *out, size_t out_size);
|
|||||||
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
|
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
|
||||||
bool elementUsesHa(const PageElement &element);
|
bool elementUsesHa(const PageElement &element);
|
||||||
|
|
||||||
|
// Whether the page uses this bitmap or icon (compared without case): as a background, as
|
||||||
|
// a fixed image, or as the "default" image of an image with an entity. by_state: also the
|
||||||
|
// names an image with an entity may show (its name start + any state).
|
||||||
|
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state = false);
|
||||||
|
|
||||||
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
|
// Entity ids look like "sensor.outside_temperature" (lower case, digits, '_', one '.');
|
||||||
// {plex_user} may be in them
|
// {plex_user} may be in them
|
||||||
bool validEntityId(const char *entity);
|
bool validEntityId(const char *entity);
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ void loop()
|
|||||||
homeAssistantLoop();
|
homeAssistantLoop();
|
||||||
webUiLoop();
|
webUiLoop();
|
||||||
appLoop();
|
appLoop();
|
||||||
|
// Once the firmware can't be rolled back anymore (the old one doesn't know icons)
|
||||||
|
if (!firmwareGuardPending())
|
||||||
|
appMigrateIcons();
|
||||||
// Home Assistant values of the shown page (read by a task, never blocks)
|
// Home Assistant values of the shown page (read by a task, never blocks)
|
||||||
if (haApiLoop(wifiConnected()))
|
if (haApiLoop(wifiConnected()))
|
||||||
controller.pageChanged();
|
controller.pageChanged();
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ void firmwareGuardStopWatchdog()
|
|||||||
watchdog_running = false;
|
watchdog_running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool firmwareGuardPending()
|
||||||
|
{
|
||||||
|
return firmware_pending;
|
||||||
|
}
|
||||||
|
|
||||||
void firmwareGuardLoop(bool wifi_connected)
|
void firmwareGuardLoop(bool wifi_connected)
|
||||||
{
|
{
|
||||||
if (watchdog_running)
|
if (watchdog_running)
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ void firmwareGuardStopWatchdog();
|
|||||||
// Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter
|
// Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter
|
||||||
void firmwareGuardLoop(bool wifi_connected);
|
void firmwareGuardLoop(bool wifi_connected);
|
||||||
|
|
||||||
|
// A new OTA firmware that is not marked as good yet (it can still be rolled back)
|
||||||
|
bool firmwareGuardPending();
|
||||||
|
|
||||||
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
|
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
|
||||||
const char *firmwareGuardStatus();
|
const char *firmwareGuardStatus();
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);bor
|
|||||||
button.d{color:var(--bad)}
|
button.d{color:var(--bad)}
|
||||||
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
|
#editor,#pe_canvas{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}#pe_canvas{cursor:move}
|
||||||
#f_canvas{width:100%;background:#000;border-radius:8px}
|
#f_canvas{width:100%;background:#000;border-radius:8px}
|
||||||
|
#ic_canvas{max-width:100%;height:auto;display:block;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
|
||||||
|
.icbox{height:64px;display:flex;align-items:center;justify-content:center;background:#000;border-radius:4px}
|
||||||
|
.item .icbox canvas{width:auto;height:auto;max-width:100%;max-height:64px;border-radius:0}
|
||||||
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px}
|
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||||
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
|
.el{border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin:6px 0}.el.sel{border-color:var(--sel)}
|
||||||
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
|
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
|
||||||
@@ -60,7 +63,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
|||||||
|
|
||||||
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
|
<section id="t-pages"><div class="card"><h2>Dynamic pages</h2><div class="grid" id="dynpages"></div>
|
||||||
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
|
<div class="row"><button class="b" onclick="peNew()">New page</button></div>
|
||||||
<p class="hint">A background per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.</p></div>
|
<p class="hint">A background (a bitmap or an icon) per time of day, with a clock, the date, Home Assistant values or text on top. Built-in pages can't be changed: Copy makes a copy you can change.</p></div>
|
||||||
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
|
<div class="card" id="pe" style="display:none"><h2 id="pe_title">Page</h2>
|
||||||
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
|
<div class="row"><input id="pe_name" placeholder="name" maxlength="24"><button class="b" onclick="peSave()">Save</button>
|
||||||
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
|
<button class="s" onclick="peLive()">Show on display now</button><button class="s" onclick="peClose()">Close</button>
|
||||||
@@ -109,7 +112,23 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
|||||||
|
|
||||||
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
|
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
|
||||||
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
|
<details id="bitmaps_used_box"><summary></summary><div class="grid" id="bitmaps_used"></div></details>
|
||||||
<p class="hint" id="storage"></p></div></section>
|
<p class="hint" id="storage"></p>
|
||||||
|
<p class="hint">Bitmaps fill the whole display and can be shown on their own. A bitmap that is only part of pages can be made an icon (Make icon).</p></div></section>
|
||||||
|
|
||||||
|
<section id="t-icons"><div class="card"><h2>Icons</h2><div class="grid" id="icons"></div>
|
||||||
|
<div class="row"><button class="b" onclick="icNew()">New icon</button></div>
|
||||||
|
<p class="hint">Small images for dynamic pages: as an Image element or as a background (drawn at the top left). They are never shown on their own, so they don't appear in the lists of pages. Make bitmap turns one into a full screen bitmap.</p></div>
|
||||||
|
<div class="card" id="ic" style="display:none"><h2 id="ic_title">Icon</h2>
|
||||||
|
<div class="row"><input id="ic_name" placeholder="name" maxlength="24"><button class="b" onclick="icSave()">Save</button>
|
||||||
|
<button class="s" onclick="icLive()">Show on display now</button><button class="s" onclick="icClose()">Close</button></div>
|
||||||
|
<div class="row"><label>Width <input type="number" id="ic_w" min="1" max="84" style="width:4.5em"></label>
|
||||||
|
<label>Height <input type="number" id="ic_h" min="1" max="19" style="width:4.5em"></label>
|
||||||
|
<button class="s" onclick="icCrop()">Crop to the dots</button><button class="s" onclick="icClear()">Clear</button><button class="s" onclick="icInvert()">Invert</button>
|
||||||
|
<label><input type="file" id="ic_file" accept="image/*"> Image</label></div>
|
||||||
|
<div class="row" id="ic_imgopts" style="display:none"><label>Threshold <input type="range" id="ic_thr" min="1" max="254" value="128"></label>
|
||||||
|
<label><input type="checkbox" id="ic_inv"> Invert</label><label><input type="checkbox" id="ic_dith"> Dither</label></div>
|
||||||
|
<canvas id="ic_canvas" width="840" height="190"></canvas>
|
||||||
|
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div></section>
|
||||||
|
|
||||||
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
||||||
<canvas id="editor" width="840" height="190"></canvas>
|
<canvas id="editor" width="840" height="190"></canvas>
|
||||||
@@ -122,8 +141,9 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
|||||||
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
|
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
|
||||||
<label><input type="checkbox" id="autolive"> live while drawing</label>
|
<label><input type="checkbox" id="autolive"> live while drawing</label>
|
||||||
<button class="s" onclick="stopLive()">Back to normal</button></div>
|
<button class="s" onclick="stopLive()">Back to normal</button></div>
|
||||||
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button></div>
|
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button>
|
||||||
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).</p></div></section>
|
<button class="s" onclick="saveAsIcon()">Save as icon</button></div>
|
||||||
|
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite). Save as icon keeps only the area with dots.</p></div></section>
|
||||||
|
|
||||||
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
|
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
|
||||||
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
|
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
|
||||||
@@ -183,12 +203,12 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
|||||||
<div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button>
|
<div class="row"><button class="s" onclick="impAll(true)">Select all</button><button class="s" onclick="impAll(false)">Select none</button>
|
||||||
<button class="b" onclick="importSel()">Import selected</button></div></div>
|
<button class="b" onclick="importSel()">Import selected</button></div></div>
|
||||||
<p class="hint" id="i_result"></p>
|
<p class="hint" id="i_result"></p>
|
||||||
<p class="hint">Export: one JSON file with the settings, the schedule and rules, and your bitmaps, dynamic pages and fonts (not the built-in ones). Single pages and bitmaps have their own Export button (a page includes the bitmaps and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.</p></div></section>
|
<p class="hint">Export: one JSON file with the settings, the schedule and rules, and your bitmaps, icons, dynamic pages and fonts (not the built-in ones). Single pages, bitmaps and icons have their own Export button (a page includes the bitmaps, icons and fonts it uses). Import: choose what to add. Items are added next to yours: a name that is taken gets a number, or type another name; only an existing name you type yourself replaces that item. Settings are only changed for the parts you tick. Without a password or token in the file, the current ones stay. Importing the MQTT settings restarts the ESP32.</p></div></section>
|
||||||
</main><div id="msg"></div>
|
</main><div id="msg"></div>
|
||||||
<script>
|
<script>
|
||||||
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
|
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
|
||||||
let settings=null,status=null,bitmapList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
|
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
|
||||||
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
|
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
|
||||||
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
|
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
|
||||||
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
|
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
|
||||||
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
|
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
|
||||||
@@ -200,9 +220,19 @@ function mk(tag,props,...kids){const e=document.createElement(tag);Object.assign
|
|||||||
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
|
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
|
||||||
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
|
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
|
||||||
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
|
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
|
||||||
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
|
function draw(c,b,w=W,h=H){const g=c.getContext('2d'),s=c.width/w;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
|
||||||
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
|
for(let y=0;y<h;y++)for(let x=0;x<w;x++){g.fillStyle=b[y*w+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
|
||||||
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
|
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
|
||||||
|
// icons: Uint8Array(w*h) <-> hex, rows of (w+7)>>3 bytes (src/esp32/logic/icon_format.h)
|
||||||
|
function icFromHex(w,h,hx){const rb=(w+7)>>3,b=new Uint8Array(w*h);for(let y=0;y<h;y++)for(let x=0;x<w;x++)b[y*w+x]=(parseInt(hx.substr((y*rb+(x>>3))*2,2),16)>>(7-(x&7)))&1;return b}
|
||||||
|
function icToHex(b,w,h){let hx='';const rb=(w+7)>>3;for(let y=0;y<h;y++)for(let i=0;i<rb;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<w&&b[y*w+x])v|=0x80>>k}hx+=v.toString(16).padStart(2,'0')}return hx}
|
||||||
|
// the icon at the top left of a full screen bitmap (how a background shows it)
|
||||||
|
function icFull(b,w,h){const f=new Uint8Array(W*H);for(let y=0;y<h;y++)for(let x=0;x<w;x++)f[y*W+x]=b[y*w+x];return f}
|
||||||
|
// the part of an image with dots: {bits,w,h,x,y}; a blank dot when there are none
|
||||||
|
function cropBits(b,w,h){let x0=w,y0=h,x1=-1,y1=-1;for(let y=0;y<h;y++)for(let x=0;x<w;x++)if(b[y*w+x]){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y)}
|
||||||
|
if(x1<0)return{bits:new Uint8Array(1),w:1,h:1,x:0,y:0};const cw=x1-x0+1,ch=y1-y0+1,o=new Uint8Array(cw*ch);
|
||||||
|
for(let y=0;y<ch;y++)for(let x=0;x<cw;x++)o[y*cw+x]=b[(y+y0)*w+x+x0];return{bits:o,w:cw,h:ch,x:x0,y:y0}}
|
||||||
|
function icCanvas(b,w,h){const s=Math.max(2,Math.min(16,Math.floor(336/w),Math.floor(76/h))),c=mk('canvas',{width:w*s,height:h*s});draw(c,b,w,h);return mk('div',{className:'icbox'},c)}
|
||||||
// mirror of the display
|
// mirror of the display
|
||||||
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
|
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
|
||||||
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
|
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
|
||||||
@@ -229,9 +259,13 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
|
|||||||
(p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
|
(p.page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||||
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
|
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
|
||||||
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
|
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
|
||||||
|
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
|
||||||
|
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
|
||||||
|
(page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||||
|
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
|
||||||
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
|
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
|
||||||
async function loadLists(){const[b,p,f,sc]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks')]);
|
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
|
||||||
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
|
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data));if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
|
||||||
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
|
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
|
||||||
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
|
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
|
||||||
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
|
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
|
||||||
@@ -247,7 +281,14 @@ async function loadLists(){const[b,p,f,sc]=await Promise.all([getList('/api/bitm
|
|||||||
$(u?'offpages_used':'offpages').appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
|
$(u?'offpages_used':'offpages').appendChild(card(bm.name,tag,data,()=>selectOff(bm.name),settings&&same(settings.off_page,bm.name)));
|
||||||
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
|
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=mk('div',{className:'row'});
|
||||||
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
|
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
|
||||||
if(!bm.builtin)r.append(btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
|
if(!bm.builtin)r.append(btn('Make icon',()=>toIcon(bm.name)),btn('Export',()=>exportItem('bitmap',bm.name)),btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);$(u?'bitmaps_used':'bitmaps').appendChild(c)}
|
||||||
|
const ics=$('icons'),users=pageList.map(p=>[p.name,pageImages(p.page)]);ics.innerHTML='';
|
||||||
|
for(const x of iconList){const by=users.filter(([,s])=>s.has(x.name.toLowerCase())).map(([n])=>n);
|
||||||
|
const d=mk('div',{className:'item'},icCanvas(x.bits,x.width,x.height),mk('div',{className:'name',textContent:x.name}),
|
||||||
|
mk('div',{className:'tag',textContent:x.width+' x '+x.height+', '+(by.length?'on '+by.join(', '):'not used'),title:by.join(', ')}),
|
||||||
|
mk('div',{className:'row'},btn('Edit',()=>icOpen(x)),btn('Make bitmap',()=>toBitmap(x.name)),btn('Export',()=>exportItem('icon',x.name)),btn('Delete',()=>delIcon(x.name),'d')));
|
||||||
|
ics.appendChild(d)}
|
||||||
|
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));
|
||||||
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
|
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
|
||||||
schedBuild();
|
schedBuild();
|
||||||
const fl=$('fontlist');fl.innerHTML='';
|
const fl=$('fontlist');fl.innerHTML='';
|
||||||
@@ -259,6 +300,9 @@ function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur
|
|||||||
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
|
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
|
||||||
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
|
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
|
||||||
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
|
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
|
||||||
|
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
|
||||||
|
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
|
||||||
|
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
|
||||||
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
|
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
|
||||||
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
|
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
|
||||||
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
|
function swText(ms){const s=Math.floor(ms/1000),h=Math.floor(s/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(h?h+':'+p(m):m)+':'+p(s%60)}
|
||||||
@@ -327,14 +371,15 @@ function chk(o,k,label){const c=mk('input',{type:'checkbox',checked:!!o[k]});c.o
|
|||||||
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
|
function inpf(o,k,props){const i=mk('input',Object.assign({type:'number',step:'any',value:o[k]},props||{}));i.oninput=()=>{if(i.value!==''&&+i.value)o[k]=+i.value;pePreview()};return i}
|
||||||
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
|
function sel(o,k,opts,after){const s=mk('select');opts.forEach(([v,t])=>s.append(mk('option',{value:v,textContent:t})));s.value=o[k];
|
||||||
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
|
s.onchange=()=>{o[k]=typeof o[k]=='number'?+s.value:s.value;if(after)after();pePreview()};return s}
|
||||||
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(bitmapList.map(b=>[b.name,b.name]));
|
function peSlots(){const d=$('pe_slots');d.innerHTML='';const opts=[['','Blank']].concat(iconList.map(x=>[x.name,x.name+' (icon)']),bitmapList.map(b=>[b.name,b.name+' (bitmap)']));
|
||||||
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
|
pe.page.slots.forEach((s,i)=>{const t=inp(s,'start','time');t.onchange=peSort;
|
||||||
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',opts),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
|
const o=s.bitmap&&!opts.some(x=>same(x[0],s.bitmap))?opts.concat([[s.bitmap,s.bitmap+' (gone)']]):opts;
|
||||||
|
d.append(mk('div',{className:'row'},'From ',t,sel(s,'bitmap',o),btn('Remove',()=>{pe.page.slots.splice(i,1);peSlots();pePreview()},'d')))});
|
||||||
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
|
if(!pe.page.slots.length)d.append(mk('p',{className:'hint',textContent:'No background: blank.'}))}
|
||||||
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
|
function peAddSlot(){if(pe.page.slots.length>=8){msg('8 time slots at most',true);return}pe.page.slots.push({start:'12:00',bitmap:''});peSort()}
|
||||||
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
|
function bySlot(a,b){return a.start<b.start?-1:a.start>b.start?1:0}
|
||||||
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
|
function peSort(){pe.page.slots.sort(bySlot);peSlots();pePreview()}
|
||||||
function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...bitmapList.map(b=>mk('option',{value:b.name}))));
|
function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{id:'bmnames'},...iconList.concat(bitmapList).map(b=>mk('option',{value:b.name}))));
|
||||||
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
|
pe.page.elements.forEach((e,i)=>{const box=mk('div',{className:'el'+(i==pe.sel?' sel':'')});box.onclick=()=>{if(pe.sel!=i){pe.sel=i;peMark()}};
|
||||||
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
|
const rows=[mk('div',{className:'row'},mk('b',{textContent:(i+1)+'. '+ETYPES[e.type]}),btn('Remove',()=>{pe.page.elements.splice(i,1);pe.sel=-1;peElems();pePreview()},'d'))];
|
||||||
const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
|
const row=(...k)=>rows.push(mk('div',{className:'row'},...k));
|
||||||
@@ -363,9 +408,9 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
|||||||
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
|
field('Before',inp(e,'text','text',{size:8,maxLength:32})),field('After',inp(e,'suffix','text',{size:6,maxLength:16})));
|
||||||
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
|
row(btn('Start',()=>stopwatch('start')),btn('Stop',()=>stopwatch('stop')),btn('Reset',()=>stopwatch('reset')))}
|
||||||
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
|
if(e.type=='image'){const name=inp(e,'text','text',{size:20,maxLength:24,placeholder:e.entity?'weather ':'name'});name.setAttribute('list','bmnames');
|
||||||
row(field(e.entity?'Bitmap name start':'Bitmap',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
|
row(field(e.entity?'Icon name start':'Icon',name),field('Entity (optional)',inp(e,'entity','text',{placeholder:'weather.knmi_home',size:24,maxLength:64})),
|
||||||
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
|
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:10,maxLength:32})),btn('Test',()=>haTest(e)));
|
||||||
rows.push(mk('p',{className:'hint',textContent:'With an entity the bitmap is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
|
rows.push(mk('p',{className:'hint',textContent:'An icon (Icons tab) or a bitmap. With an entity the icon is the name start + the state (e.g. "weather " + "cloudy" = "weather cloudy"), or the name start + "default" when there is none. The image is drawn with its top left corner at X, Y.'}))}
|
||||||
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
|
if(e.type=='bar'){row(field('Entity',inp(e,'entity','text',{placeholder:'sensor.{plex_user}_progress',size:28,maxLength:64})),
|
||||||
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
field('Attribute',inp(e,'attribute','text',{placeholder:'(the state)',size:12,maxLength:32})),btn('Test',()=>haTest(e)));
|
||||||
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
|
row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})),
|
||||||
@@ -449,19 +494,22 @@ const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
|
|||||||
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
|
function merge(a,b){for(const k in b){if(b[k]===undefined)continue;a[k]=b[k]&&typeof b[k]=='object'&&!Array.isArray(b[k])?merge(a[k]||{},b[k]):b[k]}return a}
|
||||||
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
|
function download(out,file){const a=mk('a',{href:URL.createObjectURL(new Blob([JSON.stringify(out,null,1)],{type:'application/json'})),download:file});
|
||||||
document.body.append(a);a.click();a.remove()}
|
document.body.append(a);a.click();a.remove()}
|
||||||
function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],pages:[],fonts:[]}}
|
function backup(){return{format:'flipdot-backup',version:1,firmware:status?status.version:'',exported:new Date().toISOString(),bitmaps:[],icons:[],pages:[],fonts:[]}}
|
||||||
|
const icItem=x=>({name:x.name,width:x.width,height:x.height,data:x.data});
|
||||||
async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}}
|
async function fontFile(n){const d=await api('/api/font?name='+encodeURIComponent(n));return{name:d.name,data:d.data}}
|
||||||
async function exportAll(){const sec=$('x_secrets').checked,out=backup();
|
async function exportAll(){const sec=$('x_secrets').checked,out=backup();
|
||||||
const[s,b,p,f]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts')]);
|
const[s,b,p,f,ic]=await Promise.all([api('/api/settings'+(sec?'?secrets=1':'')),api('/api/bitmaps'),api('/api/pages'),api('/api/fonts'),api('/api/icons')]);
|
||||||
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;out.schedule=await api('/api/schedule');
|
delete s.home_assistant.password_set;delete s.home_assistant.token_set;out.settings=s;out.schedule=await api('/api/schedule');
|
||||||
out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page}));
|
out.bitmaps=b.bitmaps.filter(x=>!x.builtin).map(x=>({name:x.name,data:x.data}));out.icons=ic.icons.map(icItem);out.pages=p.pages.filter(x=>!x.builtin).map(x=>({name:x.name,page:x.page}));
|
||||||
for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name));
|
for(const x of f.fonts.filter(x=>!x.builtin))out.fonts.push(await fontFile(x.name));
|
||||||
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
|
download(out,'flipdot-backup-'+out.exported.slice(0,10)+'.json');msg('Exported '+out.bitmaps.length+' bitmaps, '+out.icons.length+' icons, '+out.pages.length+' pages, '+out.fonts.length+' fonts')}
|
||||||
// one bitmap, or one page with the bitmaps and fonts it uses (not the built-in ones), without settings
|
// one bitmap or icon, or one page with the bitmaps, icons and fonts it uses (not the built-in ones), without settings
|
||||||
async function exportItem(kind,name){const out=backup();
|
async function exportItem(kind,name){const out=backup();
|
||||||
if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})}
|
if(kind=='bitmap'){const b=bitmapList.find(x=>same(x.name,name));out.bitmaps.push({name:b.name,data:b.data})}
|
||||||
else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page});
|
else if(kind=='icon')out.icons.push(icItem(iconList.find(x=>same(x.name,name))));
|
||||||
for(const s of p.page.slots){const b=bitmapList.find(x=>same(x.name,s.bitmap));if(b&&!b.builtin&&!out.bitmaps.some(x=>same(x.name,b.name)))out.bitmaps.push({name:b.name,data:b.data})}
|
else{const p=pageList.find(x=>same(x.name,name));out.pages.push({name:p.name,page:p.page});const used=pageImages(p.page);
|
||||||
|
for(const b of bitmapList)if(!b.builtin&&used.has(b.name.toLowerCase()))out.bitmaps.push({name:b.name,data:b.data});
|
||||||
|
for(const x of iconList)if(used.has(x.name.toLowerCase()))out.icons.push(icItem(x));
|
||||||
for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}}
|
for(const e of p.page.elements){const f=fontList.find(x=>same(x.name,e.font));if(f&&!f.builtin&&!out.fonts.some(x=>same(x.name,f.name)))out.fonts.push(await fontFile(f.name))}}
|
||||||
download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)}
|
download(out,'flipdot-'+kind+'-'+name.replace(/[^A-Za-z0-9_-]+/g,'-')+'.json');msg('Exported '+name)}
|
||||||
// import: settings only when ticked; items are added under a free name unless you give them an existing one
|
// import: settings only when ticked; items are added under a free name unless you give them an existing one
|
||||||
@@ -477,32 +525,33 @@ function impShow(){const d=$('i_list');d.innerHTML='';impRows=[];const ha=(imp.s
|
|||||||
if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
|
if(imp.schedule){if(!imp.settings)d.append(mk('h3',{textContent:'Settings (only the ticked parts replace yours)'}));
|
||||||
const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule';
|
const c=mk('input',{type:'checkbox',checked:false});c.dataset.key='schedule';
|
||||||
d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))}
|
d.append(mk('div',{},mk('label',{},c,' Schedule and rules ('+(imp.schedule.blocks||[]).length+' blocks, '+(imp.schedule.rules||[]).length+' rules; replaces yours)')))}
|
||||||
// bitmaps and pages share their names, fonts have their own
|
// bitmaps, icons and pages share their names, fonts have their own
|
||||||
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)),
|
const given={b:[],f:[]},takenB=n=>pageList.concat(bitmapList,iconList).some(x=>same(x.name,n))||given.b.some(x=>same(x,n)),
|
||||||
takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n));
|
takenF=n=>fontList.some(x=>same(x.name,n))||given.f.some(x=>same(x,n));
|
||||||
const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title}));
|
const group=(title,kind,list)=>{if(!list.length)return;d.append(mk('h3',{textContent:title}));
|
||||||
list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name);
|
list.forEach(item=>{const ns=kind=='f'?'f':'b',name=freeName(item.name,ns=='f'?takenF:takenB);given[ns].push(name);
|
||||||
const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})};
|
const row={kind,item,c:mk('input',{type:'checkbox',checked:true}),i:mk('input',{value:name,maxLength:24,size:20}),note:mk('span',{className:'hint'})};
|
||||||
row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})};
|
row.i.oninput=()=>impNote(row);impRows.push(row);impNote(row);d.append(mk('div',{className:'row'},row.c,row.i,row.note))})};
|
||||||
group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Dynamic pages','p',imp.pages||[]);
|
group('Fonts','f',imp.fonts||[]);group('Bitmaps','b',imp.bitmaps||[]);group('Icons','i',imp.icons||[]);group('Dynamic pages','p',imp.pages||[]);
|
||||||
$('i_box').style.display='block'}
|
$('i_box').style.display='block'}
|
||||||
function impNote(r){const n=r.i.value.trim(),own=r.kind=='f'?fontList:r.kind=='b'?bitmapList:pageList,other=r.kind=='b'?pageList:r.kind=='p'?bitmapList:[];
|
function impNote(r){const n=r.i.value.trim(),lists={f:fontList,b:bitmapList,i:iconList,p:pageList},words={b:'bitmap',i:'icon',p:'page'};
|
||||||
const replaces=own.some(x=>same(x.name,n)),clash=other.some(x=>same(x.name,n));
|
const replaces=lists[r.kind].some(x=>same(x.name,n)),clash=r.kind=='f'?'':['b','i','p'].find(k=>k!=r.kind&&lists[k].some(x=>same(x.name,n)));
|
||||||
r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+(r.kind=='b'?'page':'bitmap'):'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
|
r.note.textContent=!n?'enter a name':replaces?'replaces the existing one':clash?'name taken by a '+words[clash]:'added'+(n!=r.item.name?' (was '+r.item.name+')':'');
|
||||||
r.note.style.color=!n||replaces||clash?'var(--bad)':''}
|
r.note.style.color=!n||replaces||clash?'var(--bad)':''}
|
||||||
function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)}
|
function impAll(on){document.querySelectorAll('#i_list input[type=checkbox]').forEach(c=>c.checked=on)}
|
||||||
// fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last
|
// fonts and bitmaps before the pages that use them (renamed ones are changed in the pages), the settings last
|
||||||
async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key));
|
async function importSel(){if(!imp)return;const groups=new Set([...document.querySelectorAll('#i_list input[data-key]')].filter(c=>c.checked).map(c=>c.dataset.key));
|
||||||
const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return}
|
const rows=impRows.filter(r=>r.c.checked);if(!rows.length&&!groups.size){msg('Nothing selected',true);return}
|
||||||
const errors=[],rename={f:{},b:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...';
|
const errors=[],rename={f:{},b:{},i:{},p:{}},to=(kind,n)=>rename[kind][(n||'').toLowerCase()];let done=0,restart=false;$('i_result').textContent='Importing...';
|
||||||
const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}};
|
const run=async(label,p,body)=>{try{await api(p,body);done++;return true}catch(e){errors.push(label+': '+(e.error||'failed'));return false}};
|
||||||
for(const kind of['f','b','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim();
|
for(const kind of['f','b','i','p'])for(const r of rows.filter(r=>r.kind==kind)){const n=r.i.value.trim();
|
||||||
if(!n){errors.push(r.item.name+': no name');continue}
|
if(!n){errors.push(r.item.name+': no name');continue}
|
||||||
let ok;
|
let ok;
|
||||||
if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data});
|
if(kind=='f')ok=await run('Font '+n,'/api/fonts/save',{name:n,data:r.item.data});
|
||||||
else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data});
|
else if(kind=='b')ok=await run('Bitmap '+n,'/api/bitmap',{name:n,data:r.item.data});
|
||||||
else{const page=JSON.parse(JSON.stringify(r.item.page));(page.slots||[]).forEach(s=>s.bitmap=to('b',s.bitmap)||s.bitmap);
|
else if(kind=='i')ok=await run('Icon '+n,'/api/icon',{name:n,width:r.item.width,height:r.item.height,data:r.item.data});
|
||||||
(page.elements||[]).forEach(e=>e.font=to('f',e.font)||e.font);ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
|
else{const page=JSON.parse(JSON.stringify(r.item.page)),img=x=>to('i',x)||to('b',x)||x;(page.slots||[]).forEach(s=>s.bitmap=img(s.bitmap));
|
||||||
|
(page.elements||[]).forEach(e=>{e.font=to('f',e.font)||e.font;if(e.type=='image'&&!e.entity)e.text=img(e.text)});ok=await run('Page '+n,'/api/pages/save',{name:n,page})}
|
||||||
if(ok)rename[kind][r.item.name.toLowerCase()]=n}
|
if(ok)rename[kind][r.item.name.toLowerCase()]=n}
|
||||||
await loadLists();
|
await loadLists();
|
||||||
if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings));
|
if(groups.size){const body={};for(const[k,,part]of SGROUPS)if(groups.has(k))merge(body,part(imp.settings));
|
||||||
@@ -529,18 +578,45 @@ function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
|
|||||||
async function sendLive(){await live(ed.bits)}
|
async function sendLive(){await live(ed.bits)}
|
||||||
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
|
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
|
||||||
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
|
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
|
||||||
// image conversion
|
async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
|
||||||
|
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists()}
|
||||||
|
// image conversion: w x h dots, dark parts on
|
||||||
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
|
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
|
||||||
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
|
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
|
||||||
function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d');
|
function imgBits(img,w,h,thr,inv,dith,fit){const c=document.createElement('canvas');c.width=w;c.height=h;const g=c.getContext('2d');
|
||||||
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.height;let dw=W,dh=H;
|
g.fillStyle='#fff';g.fillRect(0,0,w,h);const iw=img.width,ih=img.height;let dw=w,dh=h;
|
||||||
if(fit!='stretch'){const s=fit=='contain'?Math.min(W/iw,H/ih):Math.max(W/iw,H/ih);dw=iw*s;dh=ih*s}
|
if(fit!='stretch'){const s=fit=='contain'?Math.min(w/iw,h/ih):Math.max(w/iw,h/ih);dw=iw*s;dh=ih*s}
|
||||||
g.drawImage(srcImg,(W-dw)/2,(H-dh)/2,dw,dh);const d=g.getImageData(0,0,W,H).data,l=new Float32Array(W*H);
|
g.drawImage(img,(w-dw)/2,(h-dh)/2,dw,dh);const d=g.getImageData(0,0,w,h).data,l=new Float32Array(w*h);
|
||||||
for(let i=0;i<W*H;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
|
for(let i=0;i<w*h;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
|
||||||
const thr=+$('thr').value,b=new Uint8Array(W*H),dith=$('dith').checked,inv=$('inv').checked;
|
const b=new Uint8Array(w*h);
|
||||||
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
|
for(let y=0;y<h;y++)for(let x=0;x<w;x++){const i=y*w+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
|
||||||
if(dith){const e=l[i]-o;if(x+1<W)l[i+1]+=e*7/16;if(y+1<H){if(x>0)l[i+W-1]+=e*3/16;l[i+W]+=e*5/16;if(x+1<W)l[i+W+1]+=e/16}}}
|
if(dith){const e=l[i]-o;if(x+1<w)l[i+1]+=e*7/16;if(y+1<h){if(x>0)l[i+w-1]+=e*3/16;l[i+w]+=e*5/16;if(x+1<w)l[i+w+1]+=e/16}}}
|
||||||
ed.set(b);changed()}
|
return b}
|
||||||
|
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
|
||||||
|
// icon editor: its own size, drawn like the bitmap editor
|
||||||
|
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
|
||||||
|
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
|
||||||
|
function icOpen(x){ic={orig:x.name,w:x.width,h:x.height,bits:x.bits.slice()};icShow('Edit '+x.name)}
|
||||||
|
function icNew(){ic={orig:'',w:11,h:11,bits:new Uint8Array(121)};icShow('New icon')}
|
||||||
|
function icClose(){ic=null;$('ic').style.display='none'}
|
||||||
|
function icDraw(){const s=Math.max(4,Math.min(40,Math.floor(840/ic.w),Math.floor(320/ic.h)));icc.width=ic.w*s;icc.height=ic.h*s;draw(icc,ic.bits,ic.w,ic.h)}
|
||||||
|
function icResize(w,h,bits,bw,bh){const b=new Uint8Array(w*h);for(let y=0;y<Math.min(h,bh);y++)for(let x=0;x<Math.min(w,bw);x++)b[y*w+x]=bits[y*bw+x];return b}
|
||||||
|
function icSize(){const w=Math.min(84,Math.max(1,Math.round(+$('ic_w').value||1))),h=Math.min(19,Math.max(1,Math.round(+$('ic_h').value||1)));
|
||||||
|
if(w==ic.w&&h==ic.h)return;ic.bits=icResize(w,h,ic.bits,ic.w,ic.h);ic.w=w;ic.h=h;if(icImg)icConvert();icDraw()}
|
||||||
|
$('ic_w').onchange=icSize;$('ic_h').onchange=icSize;
|
||||||
|
function icCrop(){const c=cropBits(ic.bits,ic.w,ic.h);Object.assign(ic,{bits:c.bits,w:c.w,h:c.h});$('ic_w').value=c.w;$('ic_h').value=c.h;icDraw()}
|
||||||
|
function icClear(){ic.bits=new Uint8Array(ic.w*ic.h);icDraw()}
|
||||||
|
function icInvert(){ic.bits=ic.bits.map(v=>1-v);icDraw()}
|
||||||
|
function icPos(e){const r=icc.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*ic.w),Math.floor((e.clientY-r.top)/r.height*ic.h)]}
|
||||||
|
function icDot(e){const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h||ic.bits[y*ic.w+x]==icPaint)return;ic.bits[y*ic.w+x]=icPaint;icDraw()}
|
||||||
|
icc.onpointerdown=e=>{if(!ic)return;const[x,y]=icPos(e);if(x<0||x>=ic.w||y<0||y>=ic.h)return;icPaint=ic.bits[y*ic.w+x]?0:1;icc.setPointerCapture(e.pointerId);icDot(e)};
|
||||||
|
icc.onpointermove=e=>{if(icPaint!==null)icDot(e)};icc.onpointerup=()=>{icPaint=null};
|
||||||
|
$('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=new Image();img.onload=()=>{icImg=img;$('ic_imgopts').style.display='flex';icConvert()};img.src=URL.createObjectURL(f)};
|
||||||
|
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert());
|
||||||
|
function icConvert(){if(!icImg||!ic)return;ic.bits=imgBits(icImg,ic.w,ic.h,+$('ic_thr').value,$('ic_inv').checked,$('ic_dith').checked,'contain');icDraw()}
|
||||||
|
async function icSave(){const n=$('ic_name').value.trim();if(!n){msg('Enter a name',true);return}
|
||||||
|
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
|
||||||
|
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
|
||||||
// test patterns (live mode)
|
// test patterns (live mode)
|
||||||
let testTimer=null;
|
let testTimer=null;
|
||||||
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
|
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "display/bitmap_store.h"
|
#include "display/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
|
#include "display/icon_store.h"
|
||||||
#include "display/page_resources.h"
|
#include "display/page_resources.h"
|
||||||
#include "display/page_store.h"
|
#include "display/page_store.h"
|
||||||
#include "logic/bitmap_format.h"
|
#include "logic/bitmap_format.h"
|
||||||
@@ -303,6 +304,73 @@ static void handleBitmapDelete()
|
|||||||
sendResult(appDeleteBitmap(body["name"] | ""));
|
sendResult(appDeleteBitmap(body["name"] | ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void handleBitmapToIcon()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
sendResult(appBitmapToIcon(body["name"] | ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Icons
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Streamed like the bitmaps
|
||||||
|
static void handleIcons()
|
||||||
|
{
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
String out = "{\"icons\":[";
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
Icon icon;
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
for (const String &name : listIcons())
|
||||||
|
{
|
||||||
|
if (!loadIcon(name.c_str(), icon))
|
||||||
|
continue;
|
||||||
|
iconToHex(icon, hex);
|
||||||
|
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
|
||||||
|
out += String(first ? "" : ",") + "{\"name\":\"" + name + "\",\"width\":" + icon.width +
|
||||||
|
",\"height\":" + icon.height + ",\"data\":\"" + hex + "\"}";
|
||||||
|
first = false;
|
||||||
|
sendPiece(out);
|
||||||
|
}
|
||||||
|
out += "]}";
|
||||||
|
sendPiece(out, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleIconSave()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
Icon icon;
|
||||||
|
if (!iconFromHex(body["width"] | 0, body["height"] | 0, body["data"] | "", icon))
|
||||||
|
{
|
||||||
|
sendError(400, "Invalid icon: 1 x 1 to 84 x 19 dots, rows of whole bytes in hex");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResult(appSaveIcon(body["name"] | "", icon));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleIconDelete()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
sendResult(appDeleteIcon(body["name"] | ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleIconToBitmap()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
sendResult(appIconToBitmap(body["name"] | ""));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dynamic pages
|
// Dynamic pages
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -751,6 +819,11 @@ void webUiBegin()
|
|||||||
server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
|
server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
|
||||||
server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
|
server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
|
||||||
server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete);
|
server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete);
|
||||||
|
server.on("/api/bitmap/to_icon", HTTP_POST, handleBitmapToIcon);
|
||||||
|
server.on("/api/icons", HTTP_GET, handleIcons);
|
||||||
|
server.on("/api/icon", HTTP_POST, handleIconSave);
|
||||||
|
server.on("/api/icon/delete", HTTP_POST, handleIconDelete);
|
||||||
|
server.on("/api/icon/to_bitmap", HTTP_POST, handleIconToBitmap);
|
||||||
server.on("/api/pages", HTTP_GET, handlePages);
|
server.on("/api/pages", HTTP_GET, handlePages);
|
||||||
server.on("/api/pages/save", HTTP_POST, handlePageSave);
|
server.on("/api/pages/save", HTTP_POST, handlePageSave);
|
||||||
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
|
server.on("/api/pages/delete", HTTP_POST, handlePageDelete);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display,
|
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display,
|
||||||
// choosing the page, dynamic pages and fonts, bitmaps (upload, draw, store), live mode,
|
// choosing the page, dynamic pages and fonts, bitmaps (upload, draw, store), icons, live mode,
|
||||||
// settings and Home Assistant. The page itself is in web/web_page.h.
|
// settings and Home Assistant. The page itself is in web/web_page.h.
|
||||||
//
|
//
|
||||||
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
|
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
// GET /api/bitmap?name= one bitmap {name, data}
|
// GET /api/bitmap?name= one bitmap {name, data}
|
||||||
// POST /api/bitmap store {name, data}
|
// POST /api/bitmap store {name, data}
|
||||||
// POST /api/bitmap/delete {name}
|
// POST /api/bitmap/delete {name}
|
||||||
|
// POST /api/bitmap/to_icon {name}: becomes an icon (not for bitmaps shown on their own)
|
||||||
|
// GET /api/icons {icons:[{name, width, height, data}]} (data: rows of
|
||||||
|
// (width + 7) / 8 bytes in hex, see logic/icon_format.h)
|
||||||
|
// POST /api/icon store {name, width, height, data}
|
||||||
|
// POST /api/icon/delete {name}
|
||||||
|
// POST /api/icon/to_bitmap {name}: becomes a full screen bitmap
|
||||||
// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]}
|
// GET /api/pages dynamic pages {pages:[{name, builtin, page, data (preview)}]}
|
||||||
// POST /api/pages/save {name, page} (page format: web/page_json.h)
|
// POST /api/pages/save {name, page} (page format: web/page_json.h)
|
||||||
// POST /api/pages/delete {name}
|
// POST /api/pages/delete {name}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "logic/builtin_fonts.h"
|
#include "logic/builtin_fonts.h"
|
||||||
#include "logic/builtin_pages.h"
|
#include "logic/builtin_pages.h"
|
||||||
#include "logic/font.h"
|
#include "logic/font.h"
|
||||||
|
#include "logic/icon_format.h"
|
||||||
#include "logic/boot_policy.h"
|
#include "logic/boot_policy.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
#include "logic/page.h"
|
#include "logic/page.h"
|
||||||
@@ -1536,6 +1537,108 @@ void test_image_element_by_state()
|
|||||||
TEST_ASSERT_NOT_NULL(validatePage(page));
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_icon_pixels_and_hex()
|
||||||
|
{
|
||||||
|
Icon icon;
|
||||||
|
icon.width = 11; // 2 bytes per row
|
||||||
|
icon.height = 3;
|
||||||
|
TEST_ASSERT_EQUAL(2, iconRowBytes(11));
|
||||||
|
TEST_ASSERT_EQUAL(6, iconBytes(11, 3));
|
||||||
|
setIconPixel(icon, 0, 0, true);
|
||||||
|
setIconPixel(icon, 10, 2, true);
|
||||||
|
setIconPixel(icon, 11, 0, true); // outside
|
||||||
|
TEST_ASSERT_TRUE(iconPixel(icon, 0, 0));
|
||||||
|
TEST_ASSERT_TRUE(iconPixel(icon, 10, 2));
|
||||||
|
TEST_ASSERT_FALSE(iconPixel(icon, 11, 0));
|
||||||
|
TEST_ASSERT_FALSE(iconPixel(icon, 1, 0));
|
||||||
|
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
iconToHex(icon, hex);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("800000000020", hex);
|
||||||
|
Icon back;
|
||||||
|
TEST_ASSERT_TRUE(iconFromHex(11, 3, hex, back));
|
||||||
|
TEST_ASSERT_EQUAL(11, back.width);
|
||||||
|
TEST_ASSERT_EQUAL(3, back.height);
|
||||||
|
TEST_ASSERT_TRUE(iconPixel(back, 10, 2));
|
||||||
|
TEST_ASSERT_FALSE(iconPixel(back, 9, 2));
|
||||||
|
|
||||||
|
// Bits after the last dot of a row are cleared
|
||||||
|
TEST_ASSERT_TRUE(iconFromHex(11, 1, "ffff", back));
|
||||||
|
TEST_ASSERT_EQUAL_HEX8(0xE0, back.data[1]);
|
||||||
|
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(11, 3, "8000", back)); // too short
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(11, 3, "80000000002g", back)); // not hex
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(0, 3, "", back));
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(85, 1, "0000000000000000000000", back));
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(1, 20, "0000000000000000000000000000000000000000", back));
|
||||||
|
TEST_ASSERT_FALSE(iconFromHex(1, 1, nullptr, back));
|
||||||
|
TEST_ASSERT_TRUE(iconFromHex(84, 19, std::string(BITMAP_HEX_LENGTH, 'f').c_str(), back));
|
||||||
|
TEST_ASSERT_TRUE(iconPixel(back, 83, 18));
|
||||||
|
TEST_ASSERT_EQUAL(84, back.width);
|
||||||
|
TEST_ASSERT_TRUE(validIconSize(1, 1));
|
||||||
|
TEST_ASSERT_FALSE(validIconSize(84, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_icon_from_bitmap_keeps_its_dots_in_place()
|
||||||
|
{
|
||||||
|
// Like "bg dag": a sun at x 9-19, y 0-10, nothing on the left
|
||||||
|
uint8_t bitmap[BITMAP_BYTES] = {};
|
||||||
|
setBitmapPixel(bitmap, 9, 5, true);
|
||||||
|
setBitmapPixel(bitmap, 19, 0, true);
|
||||||
|
setBitmapPixel(bitmap, 14, 10, true);
|
||||||
|
Icon icon;
|
||||||
|
iconFromBitmap(bitmap, icon);
|
||||||
|
TEST_ASSERT_EQUAL(20, icon.width);
|
||||||
|
TEST_ASSERT_EQUAL(11, icon.height);
|
||||||
|
uint8_t back[BITMAP_BYTES];
|
||||||
|
memset(back, 0xAA, sizeof(back));
|
||||||
|
iconToBitmap(icon, back);
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
|
||||||
|
|
||||||
|
// Full screen
|
||||||
|
memset(bitmap, 0, sizeof(bitmap));
|
||||||
|
setBitmapPixel(bitmap, 83, 18, true);
|
||||||
|
iconFromBitmap(bitmap, icon);
|
||||||
|
TEST_ASSERT_EQUAL(84, icon.width);
|
||||||
|
TEST_ASSERT_EQUAL(19, icon.height);
|
||||||
|
iconToBitmap(icon, back);
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
|
||||||
|
|
||||||
|
// No dots: a blank dot
|
||||||
|
memset(bitmap, 0, sizeof(bitmap));
|
||||||
|
iconFromBitmap(bitmap, icon);
|
||||||
|
TEST_ASSERT_EQUAL(1, icon.width);
|
||||||
|
TEST_ASSERT_EQUAL(1, icon.height);
|
||||||
|
TEST_ASSERT_FALSE(iconPixel(icon, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_page_uses_image()
|
||||||
|
{
|
||||||
|
DynamicPage page;
|
||||||
|
page.slot_count = 1;
|
||||||
|
strcpy(page.slots[0].bitmap, "bg printer");
|
||||||
|
page.element_count = 3;
|
||||||
|
page.elements[0].type = ElementType::Image;
|
||||||
|
strcpy(page.elements[0].text, "plex logo");
|
||||||
|
page.elements[1].type = ElementType::Image;
|
||||||
|
strcpy(page.elements[1].text, "weer ");
|
||||||
|
strcpy(page.elements[1].entity, "weather.home");
|
||||||
|
page.elements[2].type = ElementType::Text; // texts are no images
|
||||||
|
strcpy(page.elements[2].text, "hello");
|
||||||
|
|
||||||
|
TEST_ASSERT_TRUE(pageUsesImage(page, "BG Printer"));
|
||||||
|
TEST_ASSERT_TRUE(pageUsesImage(page, "plex logo"));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "hello"));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "plex"));
|
||||||
|
// By state: only the default, unless all states are asked for
|
||||||
|
TEST_ASSERT_TRUE(pageUsesImage(page, "weer default"));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weer sunny"));
|
||||||
|
TEST_ASSERT_TRUE(pageUsesImage(page, "Weer sunny", true));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weer", true));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weerbericht", false));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "sunny", true));
|
||||||
|
}
|
||||||
|
|
||||||
void test_moving_page_is_redrawn_when_due()
|
void test_moving_page_is_redrawn_when_due()
|
||||||
{
|
{
|
||||||
frames.wait = 250;
|
frames.wait = 250;
|
||||||
@@ -2300,6 +2403,9 @@ int main()
|
|||||||
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
|
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
|
||||||
RUN_TEST(test_render_asks_for_the_next_second);
|
RUN_TEST(test_render_asks_for_the_next_second);
|
||||||
RUN_TEST(test_image_element_by_state);
|
RUN_TEST(test_image_element_by_state);
|
||||||
|
RUN_TEST(test_icon_pixels_and_hex);
|
||||||
|
RUN_TEST(test_icon_from_bitmap_keeps_its_dots_in_place);
|
||||||
|
RUN_TEST(test_page_uses_image);
|
||||||
RUN_TEST(test_moving_page_is_redrawn_when_due);
|
RUN_TEST(test_moving_page_is_redrawn_when_due);
|
||||||
RUN_TEST(test_schedule_validation);
|
RUN_TEST(test_schedule_validation);
|
||||||
RUN_TEST(test_rule_comparisons);
|
RUN_TEST(test_rule_comparisons);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ SHOTS = [
|
|||||||
('schedule', 'schedule', 1150),
|
('schedule', 'schedule', 1150),
|
||||||
('stopwatch', 'stopwatch', 560),
|
('stopwatch', 'stopwatch', 560),
|
||||||
('bitmaps', 'bitmaps', 1600),
|
('bitmaps', 'bitmaps', 1600),
|
||||||
|
('icons', 'icons', 1900),
|
||||||
('draw', 'draw', 820),
|
('draw', 'draw', 820),
|
||||||
('settings', 'settings', 1550),
|
('settings', 'settings', 1550),
|
||||||
('home-assistant', 'ha', 780),
|
('home-assistant', 'ha', 780),
|
||||||
|
|||||||