diff --git a/.gitignore b/.gitignore
index b9f3806..7f2aa95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.pio
.vscode
+src/compile_time.h
+data/user.ini
diff --git a/README.md b/README.md
index 4427c08..7ec72a3 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,23 @@
Spotify controller application for the [ThingPulse Color Kit Grande](https://thingpulse.com/product/esp32-wifi-color-display-kit-grande/).
-[](https://thingpulse.com/product/esp32-wifi-color-display-kit-grande/)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Purpose of this project
@@ -15,11 +31,53 @@ The currently playing song can be paused, resumed and skipped to the next or pre
A full OAuth 2.0 web flow is used to acquire the necessary access and refresh tokens to permit the user to control the player.
In order to run this project on your device, you will need to setup an application on your Spotify dashboard (instructions below).
-## Features
+ ## Features
- - Artwork download
- - Control player on touch screen: play, Pause, next, prev
- - Authentication and authorization (OAuth 2.0 flow) on device.
+- **Spotify Playback Control**
+ - Play, pause, skip to next/previous track from the touch screen
+ - Control playback on any active Spotify Connect device linked to your account (e.g., phone, browser, smart speaker)
+
+- **Album Art Display**
+ - Downloads and displays album artwork via Spotify Web API
+ - Syncs background color with album art
+ - Caches artwork locally for performance
+
+- **Multiple UI Modes**
+ - Home view with track metadata and album art
+ - Cover Art only view
+ - Clock view with time and playback progress
+ - Supports 12-hour and 24-hour time formats
+ - Diagnostics view with system stats and Spotify state
+
+- **OAuth 2.0 Authorization Flow**
+ - Authentication and authorization (OAuth 2.0 flow) on device
+
+- **Designed for ESP32 + TFT Touch**
+ - Built using PlatformIO and Arduino
+ - Touch event handling for UI buttons and screen navigation
+ - Takes advantage of ESP32 dual-core architecture: UI logic runs on one core and background song and album art refreshing runs on the other
+
+- **Extensible Design**
+ - Modular architecture allows easy addition of new Views (UI screens)
+ - Built-in monitoring tools for measuring system performance and UI responsiveness
+ - Structured and tag-based logging system for easier debugging and analysis
+ - Optional `user.ini` file for easy credential and time zone management
+
+
+
+
+
+
+
+## Using the Spotify Controller
+
+- Tap the **Prev**, **Pause/Play**, and **Next** buttons to control music playback.
+- Tap the **album art** on the Home view to switch to the **Cover Art view**.
+- Tap the **clock** to switch to the **Clock view**.
+- Tap the **network status box** in the lower-right corner to open the **Diagnostics view**.
+- Tap the top-left corner for **Prev**, the top-center for **Pause/Play**, and the top-right for **Next** when using views other than Home.
+
+> For detailed display logic and diagnostics layout, see `DiagnosticsView.cpp`.
## Service level promise
@@ -37,28 +95,52 @@ See our [instructions](https://docs.thingpulse.com/guides/esp32-color-kit-grande
1. Go to [https://developer.spotify.com/dashboard/login](https://developer.spotify.com/dashboard/login) and login to or sign up for the Spotify Developer Dashboard
-2. Select "My New App"
+2. Select "Create app"
3. Fill out the form. Give your new app a name you can attribute to this project.
It's safe to select "I don't know" for the type of application.
+Add "http://tp-spotify.local/callback/" to the Redirect URIs section.
+
+ **NOTE** If you are running more than ThingPulse Spotify Remote in the same WiFi network, you should choose a unique name rather than "tp-spotify". Regardless of what you choose it has to reflect what you set for `SPOTIFY_ESPOTIFIER_NODE_NAME` in `spotify.h` in the project.
+
+
+
+ **Don't forget to save your settings.**
+
+4. Set the unique Client ID and Client Secret as values for the respective variables in `src/spotify.h`.
+
+
+
+### Filesystem setup
+
+5. Upload the file system to the device
+
+- Hit the PlatformIO icon on the navigation bar on the left side (alien face).
+
+- Select the Platform > Upload Filesystem Image task. Unless you later erase the flash or modify certain files, you only need to do this once if it succeeds. Pay attention to the output in the VS Code console that opens. If it reports any errors like e.g. if it cannot connect to the board or if stops midway, close VS Code completely, restart it, and then repeat the process.
+
+
+
+- If startup fails with the following message displayed: "FATAL ERROR - Filesystem Not Initialized", this step was not successful or done.
+
+### User settings
-
+6. The fastest way to get up and running is to open the `src/settings.h` file and adjust the handful of configuration parameters in the "User settings" section at the top. They are all documented inside the file directly. Everything should be self-explanatory. The spotify settings were updated in step 4 above.
-4. At the end of the 3 steps click "Submit"
+
-
+See [full user settings documentation](./documentation/UserSettings.md) for details about all available fields, encryption options, and using `user.ini`.
-5. Set the unique Client ID and Client Secret as values for the respective variables in `settings.h`.
+ ### Upload code to device
-
+ 7. Select the General > Upload and Monitor task. You do this every time you change code or settings.h.
-6. Click on "Edit Settings". Add "http://tp-spotify.local/callback/" to the Redirect URIs section.
+
- **NOTE** If you are running more than ThingPulse Spotify Remote in the same WiFi network, you should choose a unique name rather than "tp-spotify". Regardless of what you choose it has to reflect what you set for `SPOTIFY_ESPOTIFIER_NODE_NAME` in `settings.h` in the project.
+See [instructions](https://docs.thingpulse.com/guides/esp32-color-kit-grande/#development-environment) if you encounter problems and need Trouble Shooting tips.
-
+## Tips and Known Issues
-7. Don't forget to save your settings.
-
+To see a list of tips and known issues, see [Tips and Known Issues](./documentation/TipsAndKnownIssues.md).
\ No newline at end of file
diff --git a/boards/custom_esp-wrover-kit.json b/boards/custom_esp-wrover-kit.json
new file mode 100644
index 0000000..b86262e
--- /dev/null
+++ b/boards/custom_esp-wrover-kit.json
@@ -0,0 +1,54 @@
+{
+ "_comment": "This is a custom board configuration for an ESP32 with 8MB PSRAM. - Jan 8, 2025.",
+ "build": {
+ "arduino":{
+ "ldscript": "esp32_out.ld"
+ },
+ "core": "esp32",
+ "extra_flags": "-DARDUINO_ESP32_DEV",
+ "f_cpu": "240000000L",
+ "f_flash": "40000000L",
+ "flash_mode": "dio",
+ "hwids": [
+ [
+ "0x0403",
+ "0x6010"
+ ]
+ ],
+ "mcu": "esp32",
+ "variant": "esp32"
+ },
+ "connectivity": [
+ "wifi",
+ "bluetooth",
+ "ethernet",
+ "can"
+ ],
+ "debug": {
+ "default_tool": "ftdi",
+ "onboard_tools": [
+ "ftdi"
+ ],
+ "openocd_board": "esp32-wrover.cfg"
+ },
+ "frameworks": [
+ "arduino",
+ "espidf"
+ ],
+ "name": "Espressif ESP-WROVER-KIT",
+ "upload": {
+ "flash_size": "8MB",
+ "maximum_ram_size": 327680,
+ "maximum_size": 8388608,
+ "protocols": [
+ "esptool",
+ "espota",
+ "ftdi"
+ ],
+ "require_upload_port": true,
+ "speed": 460800
+ },
+ "url": "https://espressif.com/en/products/hardware/esp-wrover-kit/overview",
+ "vendor": "Espressif"
+ }
+
\ No newline at end of file
diff --git a/data/DefaultCoverArt.jpg b/data/DefaultCoverArt.jpg
new file mode 100644
index 0000000..82d3a2c
Binary files /dev/null and b/data/DefaultCoverArt.jpg differ
diff --git a/data/user.ini.template b/data/user.ini.template
new file mode 100644
index 0000000..27affc9
--- /dev/null
+++ b/data/user.ini.template
@@ -0,0 +1,34 @@
+;
+; User settings for Spotify Controller. Make sure to Upload Filesystem
+; Image after updating.
+;
+; ----------------------------------------------------------------------
+; Privacy Levels:
+; 0 - None (credentials in clear text)
+; 1 - Good (encrypted, reusable across devices)
+; 2 - Better (encrypted, tied to this device)
+; ----------------------------------------------------------------------
+[vault]
+privacy_level = 0
+; ----------------------------------------------------------------------
+; WiFi:
+; ----------------------------------------------------------------------
+[wifi]
+ssid = Your WiFi Name Here
+password = Your WiFi Password Here
+; ----------------------------------------------------------------------
+; Spotify:
+; ----------------------------------------------------------------------
+[spotify]
+client_id = Enter your Spotify Client ID here
+client_secret = Enter your Spotify Client Secret here
+; ----------------------------------------------------------------------
+; System Settings:
+; Timezone format - see
+; https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv
+; For US date and time formatting, include:
+; ui_date_time_format = US
+; ----------------------------------------------------------------------
+[system]
+timezone = CST6CDT,M3.2.0,M11.1.0
+ui_date_time_format = US
\ No newline at end of file
diff --git a/documentation/SCDesign.drawio b/documentation/SCDesign.drawio
new file mode 100644
index 0000000..6f2c2ee
--- /dev/null
+++ b/documentation/SCDesign.drawio
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/documentation/SCDesign.jpg b/documentation/SCDesign.jpg
new file mode 100644
index 0000000..2cc9df1
Binary files /dev/null and b/documentation/SCDesign.jpg differ
diff --git a/documentation/TipsAndKnownIssues.md b/documentation/TipsAndKnownIssues.md
new file mode 100644
index 0000000..dfb8fe2
--- /dev/null
+++ b/documentation/TipsAndKnownIssues.md
@@ -0,0 +1,35 @@
+# Tips
+- To surpress logging from the SpotifyAruduion library, goto the `SpotifyArduino.h` and commend out the following lines:
+```
+#define SPOTIFY_DEBUG 1
+
+// Comment out if you want to disable any serial output from this library (also comment out DEBUG and PRINT_JSON_PARSE)
+#define SPOTIFY_SERIAL_OUTPUT 1
+
+// Prints the JSON received to serial (only use for debugging as it will be slow)
+#define SPOTIFY_PRINT_JSON_PARSE 1
+```
+
+- If you have a Color Kit Grande with 8 MB or more of Flash memory, you can expand the album art cache from 10 albums to 60 albums by using these settings in `platformio.ini`. See the file for additional comments.
+```
+board = custom_esp-wrover-kit
+board_build.partitions = partitions/custom_no_ota.csv
+```
+
+- If you want to update your WiFi and Spotify credentials without modifying the source code, use the optional `user.ini` file. The file is ignored by Git and does not require changing code. See [full user settings documentation](./UserSettings.md) for details.
+
+# Known Issues
+- Some capabilities require a Spotify Premium subscription and may not work fully on the ad supported tier. https://developer.spotify.com/documentation/web-playback-sdk
+
+- At startup if nothing is playing the following may be logged repeatedly:
+`20:13:34.148 > [ 15318][E][ssl_client.cpp:37] _handle_error(): [data_to_read():361]: (-76) UNKNOWN ERROR CODE (004C)`
+When there isn't currently an active device or a device has been stopped for a period of time, playback controls may not work and these errors may be seen in the logs. Once music is started/resumed on the active device the errors will go away.
+
+- When playback operations are performed, the SpotifyArdiuno library may log the following. It doesn't appear to affect the operation from actually working.
+```
+23:24:36.283 > [ 67244][V][ssl_client.cpp:369] send_ssl_data(): Writing HTTP request with 0 bytes...
+23:24:36.440 > [ 67407][V][ssl_client.cpp:381] send_ssl_data(): Handling error -80
+23:24:36.446 > [ 67408][E][ssl_client.cpp:37] _handle_error(): [send_ssl_data():382]: (-80) UNKNOWN ERROR CODE (0050)
+23:24:36.452 > [ 67412][V][ssl_client.cpp:321] stop_ssl_socket(): Cleaning SSL connection.
+23:24:36.457 > Failed to send request
+```
diff --git a/documentation/UserSettings.md b/documentation/UserSettings.md
new file mode 100644
index 0000000..8496435
--- /dev/null
+++ b/documentation/UserSettings.md
@@ -0,0 +1,122 @@
+# Color Kit Grande Spotify Controller User Settings
+
+In order for this project to function, it needs to be able to connect to a WiFi network and authenticate with Spotify. This document explains how to configure it to do these things.
+
+These values must be set before the device can connect to your network or Spotify account.
+
+---
+
+## Configuration Options
+
+You can configure the settings using one of the following methods:
+
+### 1. **Edit the `settings.h` file (Quick Start)**
+
+Open the `src/settings.h` file and look for the **User Settings** section at the top. This contains the fields for Wi-Fi and Spotify credentials, as well as your timezone.
+
+This is the fastest way to get started. However, values in `settings.h` will be compiled into the firmware and are not encrypted. They will also be tracked by git unless you modify `.gitignore`. If your system backs up files and/or you copy the files to network locations, the credentials may be visible in clear text by others. To avoid these limitations, this project uses an optional `user.ini` file discussed in the next section.
+
+### 2. **Use a `user.ini` File (Recommended for Privacy and Flexibility)**
+
+A `user.ini.template` file is provided in the `/data` folder. You can copy it and rename the copy to `user.ini`, then update the values inside. This file will be uploaded to the device's filesystem using the PlatformIO “Upload Filesystem Image” task. It has already been added to `.gitignore` and will not be tracked by git by default. **NOTE: If `user.ini` is found and loaded, the conflicting values in `settings.h` will be ignored and not used.**
+
+The template includes guidance on privacy levels and where to enter your credentials.
+
+---
+
+## Required Settings
+
+| Section | Key | Description |
+|-----------|------------------|---------------------------------------------------------------------------|
+| `[vault]` | `privacy_level` | `0` = None (plaintext), `1` = Good (encrypted, portable), `2` = Better (encrypted, device-tied) |
+| `[wifi]` | `ssid` | Your Wi-Fi network name |
+| | `password` | Your Wi-Fi password |
+| `[spotify]` | `client_id` | Your Spotify Developer App Client ID |
+| | `client_secret` | Your Spotify Developer App Client Secret |
+| `[system]` | `timezone` | POSIX timezone format string (see example or link below) |
+| | `ui_date_time_format` | Optional: `US` for 12-hour clock and month-first date format; omit or use other values for international formatting |
+
+> 🔗 Refer to the [POSIX timezone list](https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv) for formatting guidance.
+
+
+## Understanding Privacy Levels
+
+The `privacy_level` setting controls how credentials are stored and handled by the device. It directly affects whether the values in your `user.ini` file are used in plaintext or must be encrypted before use.
+
+### Level 0 – None (plaintext)
+- Credentials in `user.ini` are stored and used in clear text.
+- This is the easiest option for beginners and debugging.
+- **At startup, the device will print encrypted alternatives to the serial console, which can be copy-pasted into the file if you choose to increase privacy later.**
+
+```text
+00:30:06.427 > ======================================================================
+00:30:06.432 > Paste these into your user.ini file to enable encrypted credentials.
+00:30:06.438 > ======================================================================
+00:30:06.443 >
+00:30:06.443 > ; Values to use for Good Privacy (encrypted, reusable across devices):
+00:30:06.449 > privacy_level = 1
+00:30:06.449 > ssid = kfHankF998h4coKUwRMj6w==
+00:30:06.454 > password = sFnXUDMIsmci6KV5mlh2rw==
+00:30:06.454 > client_id = NMwdXOJ8JNvkekePuwow53nHnlcbJqNKZuQSnASx/DuJBxChGn+u7rVSwVf0sf34
+00:30:06.466 > client_secret = NMwdXOJ8JNvkekePuwow53nHnlcbJqNKZuQSnASx/DuJBxChGn+u7rVSwVf0sf34
+00:30:06.471 >
+00:30:06.471 > ; Values to use for Better Privacy (encrypted, tied to this device):
+00:30:06.476 > privacy_level = 2
+00:30:06.476 > ssid = bzvfcf5V5qpzfd251wqlDQ==
+00:30:06.482 > password = /PybaE2diyJwdlH4kZC8bQ==
+00:30:06.482 > client_id = yub/jzKomMIza3IP8OAKDB04QHNvqLLtDfwWTcy/WpIg8PahhpE5gXVMYO65677j
+00:30:06.493 > client_secret = yub/jzKomMIza3IP8OAKDB04QHNvqLLtDfwWTcy/WpIg8PahhpE5gXVMYO65677j
+```
+
+### Level 1 – Good (encrypted, reusable across devices)
+- Encrypted credentials are required in `user.ini`.
+- Encryption uses AES-128 in ECB mode with PKCS#7 padding.
+- A static internal base key is used to encrypt and decrypt values.
+- Encrypted values can be reused on any compatible device.
+
+### Level 2 – Better (encrypted, tied to device)
+- Same encryption method as Level 1, but the AES key is derived from both the internal base key and the device's MAC address.
+- Encrypted values can only be decrypted by the device that created them.
+- Provides stronger security, but values must be regenerated for each device.
+
+Good and Better levels support an optional `key_salt` setting in the `[vault]` section to increase key complexity and further personalize the encryption key derivation.
+
+---
+
+## Example `user.ini`
+
+```ini
+;
+; User settings for Spotify Controller. Make sure to Upload Filesystem
+; Image after updating.
+;
+; ----------------------------------------------------------------------
+; Privacy Levels:
+; 0 - None (credentials in clear text)
+; 1 - Good (encrypted, reusable across devices)
+; 2 - Better (encrypted, tied to this device)
+; ----------------------------------------------------------------------
+[vault]
+privacy_level = 0
+; ----------------------------------------------------------------------
+; WiFi:
+; ----------------------------------------------------------------------
+[wifi]
+ssid = MyNetworkName
+password = MyPassword123
+; ----------------------------------------------------------------------
+; Spotify:
+; ----------------------------------------------------------------------
+[spotify]
+client_id = abcdefghijklmnopqrstuv1234567890
+client_secret = abcdefghijklmnopqrstuv1234567890
+; ----------------------------------------------------------------------
+; System Settings:
+; Timezone format - see
+; https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv
+; For US date and time formatting, include:
+; ui_date_time_format = US
+; ----------------------------------------------------------------------
+[system]
+timezone = CST6CDT,M3.2.0,M11.1.0
+ui_date_time_format = US
\ No newline at end of file
diff --git a/generate_compile_time.py b/generate_compile_time.py
new file mode 100644
index 0000000..6510713
--- /dev/null
+++ b/generate_compile_time.py
@@ -0,0 +1,61 @@
+import datetime
+"""
+========================================================================
+File: generate_compile_time.py
+
+Description:
+ This script generates a compile-time timestamp and writes it to a
+ header file (`compile_time.h`). The timestamp is formatted in
+ 12-hour time with an AM/PM indicator.
+
+Usage in PlatformIO:
+ - This script is executed as a pre-build step in PlatformIO.
+ - It is referenced in the `platformio.ini` file under `extra_scripts`:
+ ```
+ extra_scripts = pre:generate_compile_time.py
+ ```
+ - This ensures that every build updates the compile timestamp.
+
+Generated Output:
+ - The script writes a `#define` statement to `src/compile_time.h`:
+ ```
+ #define SC_COMPILE_TIME "MM-DD-YYYY HH:MM:SS AM/PM"
+ ```
+ - Example output:
+ ```
+ #define SC_COMPILE_TIME "03-02-2025 12:15:00 AM"
+ ```
+
+How to Use in Code:
+ - Include the generated `compile_time.h` in your source files:
+ ```c
+ #include "compile_time.h"
+ ```
+ - Print or log the compile time:
+ ```c
+ Serial.println(SC_COMPILE_TIME);
+ ```
+
+Notes:
+ - The timestamp is dynamically updated at each build.
+ - The script must be located in the root project directory for
+ PlatformIO to execute it correctly.
+========================================================================
+"""
+
+# Generate the current timestamp
+compile_time = datetime.datetime.now().strftime("%m-%d-%Y %I:%M:%S %p")
+
+# Write it to a header file with a warning comment
+with open("src/compile_time.h", "w") as f:
+ f.write("""/*
+ * DO NOT EDIT THIS FILE MANUALLY.
+ * This file is automatically generated by generate_compile_time.py
+ * and will be overwritten during each build.
+ *
+ * SPDX-FileCopyrightText: 2025 ThingPulse Ltd., https://thingpulse.com
+ * SPDX-License-Identifier: MIT
+ */
+
+""")
+ f.write(f'#define SC_COMPILE_TIME "{compile_time}"\n')
\ No newline at end of file
diff --git a/images/ClockView.jpg b/images/ClockView.jpg
new file mode 100644
index 0000000..3401aa1
Binary files /dev/null and b/images/ClockView.jpg differ
diff --git a/images/CoverArtView.jpg b/images/CoverArtView.jpg
new file mode 100644
index 0000000..7c5aa02
Binary files /dev/null and b/images/CoverArtView.jpg differ
diff --git a/images/DiagnosticView.jpg b/images/DiagnosticView.jpg
new file mode 100644
index 0000000..d1a6c57
Binary files /dev/null and b/images/DiagnosticView.jpg differ
diff --git a/images/HomeView.jpg b/images/HomeView.jpg
new file mode 100644
index 0000000..3be2320
Binary files /dev/null and b/images/HomeView.jpg differ
diff --git a/images/SpotifyAppSettings.png b/images/SpotifyAppSettings.png
deleted file mode 100644
index 3ad3c08..0000000
Binary files a/images/SpotifyAppSettings.png and /dev/null differ
diff --git a/images/SpotifyAppSettingsSave.png b/images/SpotifyAppSettingsSave.png
deleted file mode 100644
index fb32fa7..0000000
Binary files a/images/SpotifyAppSettingsSave.png and /dev/null differ
diff --git a/images/SpotifyAppSignUp1.png b/images/SpotifyAppSignUp1.png
deleted file mode 100644
index 53f589d..0000000
Binary files a/images/SpotifyAppSignUp1.png and /dev/null differ
diff --git a/images/SpotifyClientId.png b/images/SpotifyClientId.png
new file mode 100644
index 0000000..fad73e1
Binary files /dev/null and b/images/SpotifyClientId.png differ
diff --git a/images/SpotifyClientId.png_original b/images/SpotifyClientId.png_original
new file mode 100644
index 0000000..905a7f5
Binary files /dev/null and b/images/SpotifyClientId.png_original differ
diff --git a/images/SpotifyConnectScreen.png b/images/SpotifyConnectScreen.png
deleted file mode 100644
index 263a2ca..0000000
Binary files a/images/SpotifyConnectScreen.png and /dev/null differ
diff --git a/images/SpotifyCreateApp.png b/images/SpotifyCreateApp.png
new file mode 100644
index 0000000..f56921f
Binary files /dev/null and b/images/SpotifyCreateApp.png differ
diff --git a/images/SpotifyCredentials.png b/images/SpotifyCredentials.png
deleted file mode 100644
index f13f6ac..0000000
Binary files a/images/SpotifyCredentials.png and /dev/null differ
diff --git a/images/SpotifyDashboard.png b/images/SpotifyDashboard.png
index 2443966..93b7ff5 100644
Binary files a/images/SpotifyDashboard.png and b/images/SpotifyDashboard.png differ
diff --git a/images/SpotifyppSignUp3.png b/images/SpotifyppSignUp3.png
deleted file mode 100644
index 044102f..0000000
Binary files a/images/SpotifyppSignUp3.png and /dev/null differ
diff --git a/images/UserSettings.png b/images/UserSettings.png
new file mode 100644
index 0000000..973b315
Binary files /dev/null and b/images/UserSettings.png differ
diff --git a/images/platformio-filesystem.png b/images/platformio-filesystem.png
new file mode 100644
index 0000000..0f49121
Binary files /dev/null and b/images/platformio-filesystem.png differ
diff --git a/images/platformio-task-upload.png b/images/platformio-task-upload.png
new file mode 100644
index 0000000..7a52ce2
Binary files /dev/null and b/images/platformio-task-upload.png differ
diff --git a/partitions/custom_no_ota.csv b/partitions/custom_no_ota.csv
new file mode 100644
index 0000000..1a56b99
--- /dev/null
+++ b/partitions/custom_no_ota.csv
@@ -0,0 +1,17 @@
+# This is a custom partition table for this ESP32 project.
+# The custom partition is used to get access to all 8 MB
+# of the available flash memory.
+# Created 1/8/2025
+#
+# Name, Type, SubType, Offset, Size, Flags
+# Name, Type, SubType, Offset, Size, Flags
+# Non-volatile storage
+nvs, data, nvs, 0x9000, 0x5000,
+# OTA metadata (not used in no_ota)
+otadata, data, ota, 0xe000, 0x2000,
+# Application (4MB)
+app0, app, factory, 0x10000, 0x400000,
+# Filesystem (3.8125MB)
+spiffs, data, spiffs, 0x410000,0x3D0000,
+# Core dump (128KB)
+coredump, data, coredump,0x7E0000,0x20000,
\ No newline at end of file
diff --git a/platformio.ini b/platformio.ini
index b1b8041..1367ffc 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -1,58 +1,60 @@
-; PlatformIO Project Configuration File for ThingPulse Color Kit Grande
+; PlatformIO Project Configuration File
;
-; Documentation: https://docs.thingpulse.com/guides/esp32-color-kit-grande/
+; Build options: build flags, source filter
+; Upload options: custom upload port, speed and extra flags
+; Library options: dependencies, extra library storages
+; Advanced options: extra scripting
;
-; Additional PlatformIO options and examples: https://docs.platformio.org/page/projectconf.html
+; Please visit documentation for the other options and examples
+; https://docs.platformio.org/page/projectconf.html
[env:thingpulse-color-kit-grande]
-platform = espressif32@~6.4.0
+platform = espressif32@~6.10.0
+; esp-wrover-kit is 4MB ESP32-WROVER-B compatible.
board = esp-wrover-kit
+; custom_esp-wrover-kit to enable 8MB flash and 60 album art cache
+;board = custom_esp-wrover-kit
framework = arduino
-; Adjust port and speed to your system and its capabilities e.g. "upload_port = COM3" on Windows.
-; To list all availble ports you may also run 'pio device list' in the Visual Studio Code terminal window.
-; In most cases you should be able to leave this commented out and thus rely on the auto-detect mode.
-; upload_port = /dev/tty.wchusbserial54790238451
-; monitor_port = /dev/tty.wchusbserial54790238451
monitor_speed = 115200
-; For your OS & driver combination you might have to lower this to 921600 or even 460800.
upload_speed = 1500000
-monitor_filters = esp32_exception_decoder, time
-build_flags =
- ; core flags
- -DCORE_DEBUG_LEVEL=5
- -DBOARD_HAS_PSRAM
- -mfix-esp32-psram-cache-issue
- ; TFT_eSPI flags
- ; Below we replicate the flags from TFT_eSPI/User_Setups/Setup21_ILI9488.h.
- ; You can't mix'n match from their .h and -D here.
- -D USER_SETUP_LOADED=1 # 1 => will not load User_Setup.h from TFT_eSPI but rely on the flags defined here
- -D ILI9488_DRIVER=1
- -D TFT_MISO=19
- -D TFT_MOSI=18
- -D TFT_SCLK=05
- -D TFT_CS=15
- -D TFT_DC=2
- -D TFT_RST=4
- -D TFT_BL=32
- ; As we're using OpenFontRender we don't need any of the TFT_eSPI built-in fonts.
- ; Font descriptions at TFT_eSPI/User_Setups/Setup21_ILI9488.h
- -D LOAD_GLCD=0
- -D LOAD_FONT2=0
- -D LOAD_FONT4=0
- -D LOAD_FONT6=0
- -D LOAD_FONT7=0
- -D LOAD_FONT8=0
- -D LOAD_GFXFF=0
- -D SMOOTH_FONT=1
- -D SPI_FREQUENCY=27000000
- ; required if you include OpenFontRender and build on macOS
- -I /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/**
+monitor_filters =
+ esp32_exception_decoder
+ time
+build_unflags = -std=gnu++11 ; Enable C++ 14 added 1/25/2025
+build_flags =
+ -std=gnu++14 ; Enable C++ 14 to pick up std::make_unique added 1/25/2025
+ -DCORE_DEBUG_LEVEL=3 ; was 5 1/2/2025
+ -DBOARD_HAS_PSRAM
+ -mfix-esp32-psram-cache-issue
+ -D USER_SETUP_LOADED=1
+ -D ILI9488_DRIVER=1
+ -D TFT_MISO=19
+ -D TFT_MOSI=18
+ -D TFT_SCLK=05
+ -D TFT_CS=15
+ -D TFT_DC=2
+ -D TFT_RST=4
+ -D TFT_BL=32
+ -D LOAD_GLCD=0
+ -D LOAD_FONT2=0
+ -D LOAD_FONT4=0
+ -D LOAD_FONT6=0
+ -D LOAD_FONT7=0
+ -D LOAD_FONT8=0
+ -D LOAD_GFXFF=0
+ -D SMOOTH_FONT=1
+ -D SPI_FREQUENCY=27000000
+ -I /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/**
+extra_scripts = pre:generate_compile_time.py
+; no_ota.csv is 4MB ESP32-WROVER-B compatible. limits art cache to 10 albums.
board_build.partitions = no_ota.csv
+; custom_no_ota.csv is 8MB Flash ESP32-WROVER-E compatible. art cache supports 60 albums.
+; board_build.partitions = partitions/custom_no_ota.csv
board_build.filesystem = littlefs
-lib_deps =
- arkhipenko/TaskScheduler@~3.7.0
- bodmer/TFT_eSPI@~2.5.30
- bodmer/TJpg_Decoder@~1.0.8
- https://github.com/Bodmer/OpenFontRender#f163cc6 ; no tags or releases to reference :( -> pin to Git revision
- bblanchon/ArduinoJson@~6.21.3
- https://github.com/witnessmenow/spotify-api-arduino#899502c ; https://github.com/witnessmenow/spotify-api-arduino/issues/61
+lib_deps =
+ arkhipenko/TaskScheduler @ ^3.8.5
+ bodmer/TFT_eSPI @ ^2.5.43
+ bodmer/TJpg_Decoder @ ^1.1.0
+ bblanchon/ArduinoJson @ ^7.2.1
+ https://github.com/Bodmer/OpenFontRender#5e0ec22eef4bc93f963360438a9eb8e5407ef1c4
+ https://github.com/witnessmenow/spotify-api-arduino#62612780f40521e9833353eeefcaa0c5d16a97a5
diff --git a/src/DisplayUI.cpp b/src/DisplayUI.cpp
new file mode 100644
index 0000000..f803b48
--- /dev/null
+++ b/src/DisplayUI.cpp
@@ -0,0 +1,1873 @@
+/*-------------------------------------------------------------------
+**
+** DisplayUI.cpp
+**
+** Provides methods for drawing UI elements including album art, buttons,
+** progress indicators, and text, using the TFT_eSPI and OpenFontRender libraries.
+**
+** Originally based on https://github.com/Bodmer/OpenWeather/blob/main/examples/TFT_eSPI_OpenWeather_LittleFS/GfxUi.h
+**
+** SPDX-FileCopyrightText: 2025 ThingPulse Ltd., https://thingpulse.com
+** SPDX-License-Identifier: MIT
+**
+** ------------------------------------------------------------------------------------------------
+** Change Log:
+** 2024-12-26 - Electric Diversions - Renamed from GfxUI.h.h to tpGfxUi.h
+** 2024-12-27 - Electric Diversions - Renamed to DisplayUI and promoted to base class
+** ------------------------------------------------------------------------------------------------
+**
+*/
+
+/*
+** ------------------------------------------------------------------------------------------------
+**
+** NOTE: DMA (Direct Memory Access) is *not* enabled for ILI9488.
+**
+** The ILI9488 display controller requires 24-bit (RGB888) color
+** over SPI, while the ESP32's SPI DMA engine only supports 8-bit
+** and 16-bit transfers. Because of this, the TFT_eSPI library
+** disables DMA support for ILI9488 by default.
+**
+** Enabling DMA would require additional processing to convert
+** 16-bit (RGB565) color data into the 24-bit format, eliminating
+** any potential performance benefits. It could also introduce
+** graphical artifacts or instability.
+**
+** For this project, standard SPI transfers are used instead.
+**
+** 02/15/2025 - Documented DMA decision
+**
+** ------------------------------------------------------------------------------------------------
+*/
+
+#include // for fmod, fabs
+#include // for std::min, std::max
+#include
+
+#include "DisplayUI.h"
+#include "settings.h"
+#include "logTags.h"
+#include "SCLogger.h"
+#include "Monitor.h"
+#include "ThingPulse/util.h"
+
+#define FS_TP_LOGO "/ThingPulse-logo-260.jpeg"
+
+/*
+** ===================================================================
+** toValue()
+** Helper function to convert TFTColor to standard color value
+** ===================================================================
+*/
+constexpr uint16_t toValue(TFTColor color)
+{
+ return static_cast(color);
+}
+
+/*
+** ===================================================================
+** toRGB565()
+** Converts a color from RGB565 (16-bit) or RGB888 (24-bit) to
+** 16-bit RGB565 format.
+**
+** Parameters:
+** color - Can be either:
+** - A 16-bit RGB565 color (TFTColor enum)
+** - A 24-bit RGB888 color (0xRRGGBB)
+**
+** Returns:
+** A 16-bit RGB565 color.
+**
+** Notes:
+** - If the input is already RGB565 (16-bit), it is returned as-is.
+** - If the input is RGB888 (24-bit), it is converted to RGB565.
+** - This function is useful for converting colors for TFT displays.
+**
+** Example Usage:
+** uint16_t red565 = toRGB565(TFTColor::Red);
+** uint16_t customBlue = toRGB565(0x0067B0); // Custom RGB888 color
+**
+** _ofr->cdrawString("RED", posX, posY, red565, toRGB565(TFTColor::Black));
+** ===================================================================
+*/
+uint16_t toRGB565(TFTColor tftColor)
+{
+
+ // Convert to unsigned int
+ uint32_t color = toValue(tftColor);
+
+ // If color is 16-bit (RGB565), return it as-is
+ if (color <= 0xFFFF) {
+ return static_cast(color);
+ }
+
+ // If color is 24-bit (RGB888), convert it to RGB565
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red (8-bit)
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green (8-bit)
+ uint8_t b = color & 0xFF; // Extract Blue (8-bit)
+
+ // Convert 8-bit RGB888 to 5-6-5 RGB565 format
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
+}
+
+// TODO: Standardize singleton instantiation across the project.
+static DisplayUI *pInstance = nullptr;
+
+/*
+** ===================================================================
+** DisplayUI() Constructor
+** ===================================================================
+*/
+DisplayUI::DisplayUI(TFT_eSPI *tft, OpenFontRender *ofr, OpenFontRender *clockFont)
+{
+ _tft = tft;
+ _ofr = ofr;
+ _clockFont = clockFont;
+
+ TJpgDec.setJpgScale(1);
+ TJpgDec.setCallback(DisplayUI::jpgCallback);
+}
+
+/*
+** ===================================================================
+** init()
+** Initialize the singleton.
+** ===================================================================
+*/
+void DisplayUI::init()
+{
+ pInstance = this;
+}
+
+/*
+** ===================================================================
+** isUIDirty()
+** Determines if the UI needs to be updated. Returns true if the
+** UI is marked as dirty, false otherwise.
+** ===================================================================
+*/
+bool DisplayUI::isUIDirty()
+{
+ bool answer = false;
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ answer = _isUIDirty;
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"DisplayUI::isUIDirty - Unable to take xSemaphoreDisplay.");
+ }
+
+ return answer;
+}
+
+/*
+** ===================================================================
+** markUIDirty()
+** Marks the UI as dirty or not dirty, based on the value of
+** the isDirty parameter.
+** ===================================================================
+*/
+void DisplayUI::markUIDirty(bool isDirty)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _isUIDirty = isDirty;
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"DisplayUI::markUIDirty - Unable to take xSemaphoreDisplay.");
+ }
+}
+
+/*
+** ===================================================================
+** getCenterWidth()
+** ===================================================================
+*/
+int16_t DisplayUI::getCenterWidth()
+{
+ return _tft->width() / 2;
+}
+
+/*
+** ===================================================================
+** getCenterHeight()
+** ===================================================================
+*/
+int16_t DisplayUI::getCenterHeight()
+{
+ return _tft->height() / 2;
+}
+
+/*
+** ===================================================================
+** getWidth()
+** ===================================================================
+*/
+int16_t DisplayUI::getWidth()
+{
+ return _tft->width();
+}
+
+/*
+** ===================================================================
+** getHeight()
+** ===================================================================
+*/
+int16_t DisplayUI::getHeight()
+{
+ return _tft->height();
+}
+
+/*
+** ===================================================================
+** drawBmp()
+** Renders a 24-bit BMP image from the LittleFS filesystem onto
+** the display at the specified (x, y) position. Optimized for
+** performance using a no-seek buffer and direct line decoding.
+**
+** Parameters:
+** filename - Path to the BMP file in the LittleFS filesystem.
+** x - X-coordinate for the top-left corner of the image.
+** y - Y-coordinate for the top-left corner of the image.
+**
+** Notes:
+** - Only uncompressed 24-bit BMP files are supported.
+** - Image is drawn bottom-up as per BMP specification.
+** - Invalid formats or missing files will generate log errors.
+**
+** Example Usage:
+** drawBmp("/splash.bmp", 10, 20);
+** ===================================================================
+*/
+
+// Bodmer's streamlined x2 faster "no seek" version
+void DisplayUI::drawBmp(String filename, uint16_t x, uint16_t y) {
+
+ if ((x >= _tft->width()) || (y >= _tft->height()))
+ return;
+
+ fs::File bmpFS;
+
+ // Note: ESP32 passes "open" test even if file does not exist, whereas ESP8266
+ // returns NULL
+ if (!LittleFS.exists(filename)) {
+ spLogE(LOGTAG_GUI, " File not found");
+ return;
+ }
+
+ // Open requested file
+ bmpFS = LittleFS.open(filename, "r");
+
+ uint32_t seekOffset;
+ uint16_t w, h, row;
+ uint8_t r, g, b;
+ bool oldSwap = false;
+
+ if (read16(bmpFS) == 0x4D42) {
+ read32(bmpFS);
+ read32(bmpFS);
+ seekOffset = read32(bmpFS);
+ read32(bmpFS);
+ w = read32(bmpFS);
+ h = read32(bmpFS);
+
+ if ((read16(bmpFS) == 1) && (read16(bmpFS) == 24) && (read32(bmpFS) == 0)) {
+ y += h - 1;
+
+ oldSwap = _tft->getSwapBytes();
+ _tft->setSwapBytes(true);
+ bmpFS.seek(seekOffset);
+
+ // Calculate padding to avoid seek
+ uint16_t padding = (4 - ((w * 3) & 3)) & 3;
+ uint8_t lineBuffer[w * 3 + padding];
+
+ for (row = 0; row < h; row++) {
+
+ bmpFS.read(lineBuffer, sizeof(lineBuffer));
+ uint8_t *bptr = lineBuffer;
+ uint16_t *tptr = (uint16_t *)lineBuffer;
+ // Convert 24 to 16 bit colours using the same line buffer for results
+ for (uint16_t col = 0; col < w; col++) {
+ b = *bptr++;
+ g = *bptr++;
+ r = *bptr++;
+ *tptr++ = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
+ }
+
+ // Push the pixel row to screen, pushImage will crop the line if needed
+ // y is decremented as the BMP image is drawn bottom up
+ _tft->pushImage(x, y--, w, 1, (uint16_t *)lineBuffer);
+ }
+ } else
+ spLogE(LOGTAG_GUI, "BMP format not recognized.");
+ }
+ _tft->setSwapBytes(oldSwap);
+ bmpFS.close();
+}
+
+/*
+** ===================================================================
+** drawLogo()
+** ===================================================================
+*/
+void DisplayUI::drawLogo() {
+ if (LittleFS.exists(FS_TP_LOGO)) {
+ uint16_t w = 0, h = 0;
+ TJpgDec.getFsJpgSize(&w, &h, FS_TP_LOGO, LittleFS);
+ TJpgDec.drawFsJpg((_tft->width() - w) / 2, 30, FS_TP_LOGO, LittleFS);
+ }
+}
+
+/*
+** ===================================================================
+** drawAppInfo()
+** ===================================================================
+*/
+void DisplayUI::drawAppInfo() {
+ _ofr->setFontSize(16);
+ _ofr->cdrawString(APP_NAME, getCenterWidth(), _tft->height() - 50);
+ _ofr->cdrawString(VERSION, getCenterWidth(), _tft->height() - 30);
+}
+
+/*
+** ===================================================================
+** drawAlbumArt()
+** ===================================================================
+*/
+void DisplayUI::drawAlbumArt(int32_t x, int32_t y, String filename)
+{
+ spLogI(LOGTAG_GUI, "Entering drawAlbumArt. ttf width is %d. filename is %s.", _tft->width(), filename.c_str());
+
+ if (LittleFS.exists(filename))
+ {
+ uint16_t w = 0, h = 0;
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ TJpgDec.getFsJpgSize(&w, &h, filename, LittleFS);
+ spLogI(LOGTAG_GUI, "After getFsJpgSize(). ttf width is %d. w is %d. filename is %s.", _tft->width(), w, filename.c_str());
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+ Monitor::start(MONITOR_ID_SPOTIFY_IMAGE_FILE_LOAD, LOGTAG_METRICS, "drawFsJpg(...)");
+ drawFsJpg(x, y, filename.c_str(), LittleFS);
+
+ Monitor::stop(MONITOR_ID_SPOTIFY_IMAGE_FILE_LOAD);
+
+ }
+
+}
+
+/*
+** ===================================================================
+** drawProgressBar()
+** ===================================================================
+*/
+void DisplayUI::drawProgressBar(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h,
+ uint8_t percentage, TFTColor frameColor,
+ TFTColor barColor)
+{
+ if (percentage == 0)
+ {
+ _tft->fillRoundRect(x0, y0, w, h, 3, TFT_BLACK);
+ }
+ uint8_t margin = 2;
+ uint16_t barHeight = h - 2 * margin;
+ uint16_t barWidth = w - 2 * margin;
+
+ _tft->drawRoundRect(x0, y0, w, h, 3, toValue(frameColor));
+
+ _tft->fillRect(x0 + margin, y0 + margin, barWidth * percentage / 100.0,
+ barHeight, toValue(barColor));
+}
+
+/*
+** ===================================================================
+** drawStatusBox()
+** This call should be thread safe.
+** ===================================================================
+*/
+void DisplayUI::drawStatusBox(TFTColor statusColor)
+{
+ uint8_t margin = 2;
+ int32_t boxX = 450; //460; //10;
+ int32_t boxY = 290; //300;
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _tft->drawRoundRect(boxX, boxY, 20, 20, 3, toValue(TFTColor::DarkGrey));
+ _tft->fillRect(boxX + margin, boxY + margin, 16, 16, toValue(statusColor));
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawStatusBox().");
+ }
+ }
+
+/*
+** ===================================================================
+** drawDownloadIndicator()
+** This call should be thread safe.
+** ===================================================================
+*/
+void DisplayUI::drawDownloadIndicator(bool bDownloadComplete)
+{
+ uint8_t margin = 2;
+ int32_t boxX = 425; //460; //10;
+ int32_t boxY = 290; //300;
+
+ TFTColor lineColor = TFTColor::Black;
+ TFTColor fillColor = TFTColor::Black;
+
+ if (!isSplitBackground())
+ {
+ lineColor = getBackground();
+ fillColor = getBackground();
+ }
+
+ if (!bDownloadComplete)
+ {
+ lineColor = TFTColor::White;
+ fillColor = TFTColor::SkyBlue;
+ }
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _tft->drawRoundRect(boxX, boxY, 20, 20, 3, toValue(lineColor));
+ _tft->fillRect(boxX + margin, boxY + margin, 16, 16, toValue(fillColor));
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawStatusBox().");
+ }
+ }
+
+/*
+** ===================================================================
+** drawButton()
+** This call should be thread safe.
+** ===================================================================
+*/
+void DisplayUI::drawButton(int32_t x, int32_t y, bool isPressed)
+{
+ const uint8_t margin = 2;
+ const int32_t width = 90;
+ const int32_t height = 90;
+
+ drawBlankButton(x,y,width,height,margin,TFTColor::White,isPressed);
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Draw rewind symbol (|<)
+ const int32_t centerX = x + width / 2;
+ const int32_t centerY = y + height / 2;
+ const int32_t triangleSize = 20;
+ const int32_t lineThickness = 5; // Adjust thickness of vertical line
+ const int32_t lineX = centerX - 20; // Shifted left for proper placement
+ const int32_t triangleX = lineX + lineThickness + 20;//2; // Right next to the line
+ const int32_t triangleY = centerY - 15;
+ const TFTColor symbolColor = TFTColor::White;
+
+ // Draw vertical line '|' using a filled rectangle for thickness
+ _tft->fillRect(lineX, centerY - 15, lineThickness, 30, toValue(symbolColor));
+
+ // Draw left-facing triangle '<'
+ _tft->fillTriangle(
+ triangleX + 15, triangleY, // Top point
+ triangleX + 15, triangleY + 30, // Bottom point
+ triangleX - triangleSize, centerY, // Leftmost point
+ toValue(symbolColor)
+ );
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawStatusBox().");
+ }
+ }
+
+
+/*
+** ===================================================================
+** drawBlankButton()
+** Draws a blank button with optional pressed effect and specified border color.
+** Thread-safe via xSemaphoreDisplay.
+** ===================================================================
+*/
+void DisplayUI::drawBlankButton(int32_t x, int32_t y, int32_t width, int32_t height, uint8_t margin, TFTColor borderColor, bool isPressed)
+{
+
+ TFTColor bodyColor = getBackground();
+ // Invert the color
+ TFTColor pressColor = static_cast(~static_cast(bodyColor));
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _tft->drawRoundRect(x, y, width, height, 3, toValue(borderColor));
+
+ if (isPressed)
+ {
+ _tft->fillRect(x + margin, y + margin, width - margin * 2, height - margin * 2, toValue(pressColor));
+ }
+ else
+ {
+ _tft->fillRect(x + margin, y + margin, width - margin * 2, height - margin * 2, toValue(bodyColor));
+ }
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawStatusBox().");
+ }
+}
+
+/*
+** ===================================================================
+** drawSkipTrackIcon()
+** Draws a skip track icon. Direction is determined by isReversed flag.
+** Thread-safe via xSemaphoreDisplay.
+** ===================================================================
+*/
+void DisplayUI::drawSkipTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height, bool isReversed)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Define basic geometry
+ const int32_t centerX = x + width / 2;
+ const int32_t centerY = y + height / 2;
+ const int32_t triangleSize = 20;
+ const int32_t lineThickness = 5; // Thickness of the vertical line
+ const int32_t offsetX = 20; // Offset to adjust positioning
+
+ // Determine positions based on isReversed
+ const int32_t lineX = isReversed ? centerX - offsetX : centerX + offsetX - lineThickness;
+ const int32_t triangleX = isReversed ? (lineX + lineThickness + 20) : (lineX - 20);
+ const int32_t triangleY = centerY - 15;
+ const TFTColor symbolColor = TFTColor::White;
+
+ // Draw vertical line '|'
+ _tft->fillRect(lineX, centerY - 15, lineThickness, 30, toValue(symbolColor));
+
+ // Draw triangle '<' or '>'
+ if (isReversed)
+ {
+ // Left-facing triangle '<'
+ _tft->fillTriangle(
+ triangleX + 15, triangleY, // Top point
+ triangleX + 15, triangleY + 30, // Bottom point
+ triangleX - triangleSize, centerY, // Leftmost point
+ toValue(symbolColor)
+ );
+ }
+ else
+ {
+ // Right-facing triangle '>'
+ _tft->fillTriangle(
+ triangleX - 15, triangleY, // Top point
+ triangleX - 15, triangleY + 30, // Bottom point
+ triangleX + triangleSize, centerY, // Rightmost point
+ toValue(symbolColor)
+ );
+ }
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawSkipTrackIcon().");
+ }
+}
+
+/*
+** ===================================================================
+** drawPlayTrackIcon()
+** Draws a right-facing triangle icon for the "play" action.
+** ===================================================================
+*/
+void DisplayUI::drawPlayTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Define basic geometry
+ const int32_t centerX = x + width / 2;
+ const int32_t centerY = y + height / 2;
+ const int32_t triangleSize = 20;
+ const int32_t lineThickness = 0; // Thickness of the vertical line
+ const int32_t offsetX = 20; // Offset to adjust positioning
+
+ // Determine positions based on isReversed
+ const int32_t lineX = centerX + offsetX - lineThickness;
+ const int32_t triangleX = lineX - 20;
+ const int32_t triangleY = centerY - 15;
+ const TFTColor symbolColor = TFTColor::White;
+
+ // Right-facing triangle '>'
+ _tft->fillTriangle(
+ triangleX - 15, triangleY, // Top point
+ triangleX - 15, triangleY + 30, // Bottom point
+ triangleX + triangleSize, centerY, // Rightmost point
+ toValue(symbolColor)
+ );
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawSkipTrackIcon().");
+ }
+}
+
+/*
+** ===================================================================
+** drawPauseTrackIcon()
+** Draws two vertical bars to represent the "pause" icon.
+** ===================================================================
+*/
+void DisplayUI::drawPauseTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Define basic geometry
+ const int32_t centerX = x + width / 2;
+ const int32_t centerY = y + height / 2;
+ const int32_t lineThickness = 5; // Thickness of the vertical line
+ const TFTColor symbolColor = TFTColor::White;
+
+ // Draw vertical line '|'
+ _tft->fillRect(centerX - lineThickness * 2, centerY - 15, lineThickness, 30, toValue(symbolColor));
+ _tft->fillRect(centerX + lineThickness * 2, centerY - 15, lineThickness, 30, toValue(symbolColor));
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawSkipTrackIcon().");
+ }
+}
+
+/*
+** ===================================================================
+** drawBackIcon()
+** Draws a back arrow icon pointing to the left.
+** ===================================================================
+*/
+void DisplayUI::drawBackIcon(int32_t x, int32_t y, int32_t width, int32_t height)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Define basic geometry
+ const int32_t centerX = x + width / 2;
+ const int32_t centerY = y + height / 2;
+ const int32_t arrowWidth = 20;
+ const int32_t arrowHeight = 10;
+ const int32_t lineThickness = 5;
+ const TFTColor symbolColor = TFTColor::SC_NetworkSuccess; //::White;
+
+ // Draw left-facing arrow '<-'
+ // Draw the line part of the arrow
+ _tft->fillRect(centerX - lineThickness, centerY - lineThickness / 2, arrowWidth, lineThickness, toValue(symbolColor));
+
+ // Draw the triangular tip of the arrow
+ _tft->fillTriangle(
+ centerX - arrowWidth, centerY, // Tip of the arrow
+ centerX, centerY - arrowHeight, // Top point of triangle
+ centerX, centerY + arrowHeight, // Bottom point of triangle
+ toValue(symbolColor)
+ );
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay in drawBackTrackIcon().");
+ }
+}
+
+/*
+** ===================================================================
+** read16()
+** ===================================================================
+*/
+
+// These read 16- and 32-bit types from the SD card file.
+// BMP data is stored little-endian, Arduino is little-endian too.
+// May need to reverse subscript order if porting elsewhere.
+
+uint16_t DisplayUI::read16(fs::File &f)
+{
+ uint16_t result;
+ ((uint8_t *)&result)[0] = f.read(); // LSB
+ ((uint8_t *)&result)[1] = f.read(); // MSB
+ return result;
+}
+
+/*
+** ===================================================================
+** read32()
+** ===================================================================
+*/
+uint32_t DisplayUI::read32(fs::File &f)
+{
+ uint32_t result;
+ ((uint8_t *)&result)[0] = f.read(); // LSB
+ ((uint8_t *)&result)[1] = f.read();
+ ((uint8_t *)&result)[2] = f.read();
+ ((uint8_t *)&result)[3] = f.read(); // MSB
+ return result;
+}
+
+/*
+** ===================================================================
+** drawProgress()
+** ===================================================================
+*/
+void DisplayUI::drawProgress(const char *text, int8_t percentage) {
+ _ofr->setFontSize(24);
+ int pbWidth = _tft->width() - 100;
+ int pbX = (_tft->width() - pbWidth)/2;
+ int pbY = 210; //260;
+ int progressTextY = 160; //210;
+
+ _tft->fillRect(0, progressTextY, _tft->width(), 40, TFT_BLACK);
+ _ofr->cdrawString(text, getCenterWidth(), progressTextY);
+ drawProgressBar(pbX, pbY, pbWidth, 15, percentage, TFTColor::White, TFTColor::BlueThinkPulse);
+}
+
+/*
+** ===================================================================
+** drawSeparator()
+** ===================================================================
+*/
+void DisplayUI::drawSeparator(uint16_t y)
+{
+ _tft->drawFastHLine(10, y, _tft->width() - 2 * 15, 0x4228);
+}
+
+/*
+** ===================================================================
+** clearScreen()
+** ===================================================================
+*/
+void DisplayUI::clearScreen()
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+
+ _tft->fillScreen(toValue(getBackground()));
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** clearScreenKeepArt() - clear area around art
+** ===================================================================
+*/
+void DisplayUI::clearScreenKeepArt()
+{
+ const int32_t lineSize = 10;
+ const int32_t borderSize = (_tft->width() - 300) / 2; // 300 is cover art size
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Top
+ _tft->fillRect(0, 0, _tft->width() , lineSize, toValue(getBackground()));
+
+ // Left
+ _tft->fillRect(0, 0, borderSize, _tft->height(), toValue(getBackground()));
+
+ // Right
+ _tft->fillRect(_tft->width() - borderSize, 0, borderSize, _tft->height(), toValue(getBackground()));
+
+ // Bottom
+ _tft->fillRect(0, _tft->height() - lineSize, _tft->width() , lineSize, toValue(getBackground()));
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** clearScreenKeepArt() - clear area around art
+** ===================================================================
+*/
+void DisplayUI::clearScreenHome()
+{
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Top
+ _tft->fillRect(0, 0, _tft->width() , 200, toValue(getBackground()));
+
+ // Bottom
+ _tft->fillRect(0,200, _tft->width() , 320, toValue(TFTColor::Black));
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** getBackground()
+** Returns the current background color of the UI.
+** ===================================================================
+*/
+TFTColor DisplayUI::getBackground() const
+{
+ return _bgColor;
+}
+
+
+/*
+** ===================================================================
+** isSplitBackground()
+** ===================================================================
+*/
+bool DisplayUI::isSplitBackground() const
+{
+ // spLogI(LOGTAG_GENERAL,"isSplitBackground() returning %u", _isSplitBackground);
+ return _isSplitBackground;
+}
+
+/*
+** ===================================================================
+** setSplitBackground()
+** ===================================================================
+*/
+void DisplayUI::setSplitBackground(bool isSplitBackground)
+{
+ // spLogI(LOGTAG_GENERAL,"setting _isSplitBackground to %u", isSplitBackground);
+ _isSplitBackground = isSplitBackground;
+}
+
+/*
+** ===================================================================
+** setBackground()
+** ===================================================================
+*/
+void DisplayUI::setBackground(TFTColor color, bool bRepaint)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _bgColor = color;
+ if (bRepaint)
+ {
+ _tft->fillScreen(toValue(color));
+ }
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** drawTextToLCD()
+** Displays text on the LCD at the specified vertical position,
+** using a default font size.
+** ===================================================================
+*/
+void DisplayUI::drawTextToLCD(const char *text, int posY)
+{
+
+ int pbWidth = _tft->width() - 100;
+ int pbX = (_tft->width() - pbWidth)/2;
+ int pbY = 260;
+ int textY = posY;
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _ofr->setFontSize(24);
+ _tft->fillRect(0, textY, _tft->width(), 40, toValue(TFTColor::Black));
+ _ofr->cdrawString(text, getCenterWidth(), textY);
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** drawTextToLCD()
+** Overloaded version of drawTextToLCD that allows specifying
+** font size and whether to ignore rendering the text.
+** ===================================================================
+*/
+void DisplayUI::drawTextToLCD(const char *text, int posY, int fontSize, bool ignoreText)
+{
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _ofr->setFontSize(fontSize);
+
+ if (ignoreText)
+ {
+ _tft->fillRect(0, posY, _tft->width(), fontSize + 10, toValue(getBackground()));
+ }
+ else
+ {
+ _tft->fillRect(0, posY, _tft->width(), fontSize + 10, toValue(TFTColor::Black));
+ _ofr->cdrawString(text, getCenterWidth(), posY);
+ }
+
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** rgb565()
+** Converts 24-bit RGB888 color to 16-bit RGB565 format.
+** ===================================================================
+*/
+uint16_t rgb565(uint32_t color) {
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green
+ uint8_t b = color & 0xFF; // Extract Blue
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
+}
+
+/*
+** ===================================================================
+** bgr565()
+** Converts 24-bit RGB888 color to 16-bit RGB565 with red and blue swapped.
+** ===================================================================
+*/
+uint16_t bgr565(uint32_t color) {
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green
+ uint8_t b = color & 0xFF; // Extract Blue
+ return ((b & 0xF8) << 8) | ((g & 0xFC) << 3) | (r >> 3); // Swap Red and Blue
+}
+
+/*
+** ===================================================================
+** grb565()
+** Converts 24-bit RGB888 color to 16-bit RGB565 with red and green swapped.
+** ===================================================================
+*/
+uint16_t grb565(uint32_t color) {
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green
+ uint8_t b = color & 0xFF; // Extract Blue
+ return ((g & 0xF8) << 8) | ((r & 0xFC) << 3) | (b >> 3); // Swap Red and Green
+}
+
+/*
+** ===================================================================
+** brg565()
+** Converts 24-bit RGB888 color to 16-bit RGB565 with blue and green swapped.
+** ===================================================================
+*/
+uint16_t brg565(uint32_t color) {
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green
+ uint8_t b = color & 0xFF; // Extract Blue
+ return ((b & 0xF8) << 8) | ((r & 0xFC) << 3) | (g >> 3); // Swap Blue and Green
+}
+
+/*
+** ===================================================================
+** rgb888_to_rgb565()
+** Converts a 24-bit RGB888 color value to 16-bit RGB565 format.
+** ===================================================================
+*/
+uint16_t rgb888_to_rgb565(uint32_t color) {
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red (8-bit)
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green (8-bit)
+ uint8_t b = color & 0xFF; // Extract Blue (8-bit)
+
+ // Convert 8-bit RGB888 to 5-6-5 RGB565 format
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
+}
+
+/*
+** ===================================================================
+** convertColorForILI9488()
+** Converts a color from RGB565 (16-bit) or RGB888 (24-bit) to
+** the correct format for the ILI9488 display. Ensures that colors
+** defined in the TFTColor enum render correctly.
+**
+** Parameters:
+** color - Can be either:
+** - A 16-bit RGB565 color (from TFTColor enum)
+** - A 24-bit RGB888 color (0xRRGGBB)
+**
+** Returns:
+** 16-bit RGB565 color, correctly formatted for the ILI9488.
+**
+** Notes:
+** - If the input is already RGB565 (16-bit), it is returned as-is.
+** - If the input is RGB888 (24-bit), it is converted to RGB565.
+** - This ensures that colors from the TFTColor enum render correctly.
+**
+** Example Usage:
+** uint16_t red565 = convertColorForILI9488(TFTColor::Red);
+** uint16_t customBlue = convertColorForILI9488(0x0067B0); // Custom RGB888 color
+**
+** _ofr->cdrawString("RED", posX, posY, red565, convertColorForILI9488(TFTColor::Black));
+** ===================================================================
+*/
+uint16_t convertColorForILI9488(uint32_t color) {
+ // If color is 16-bit (RGB565), return it as-is
+ if (color <= 0xFFFF) {
+ return static_cast(color);
+ }
+
+ // If color is 24-bit (RGB888), convert it to RGB565
+ uint8_t r = (color >> 16) & 0xFF; // Extract Red (8-bit)
+ uint8_t g = (color >> 8) & 0xFF; // Extract Green (8-bit)
+ uint8_t b = color & 0xFF; // Extract Blue (8-bit)
+
+ // Convert 8-bit RGB888 to 5-6-5 RGB565 format
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
+}
+
+/*
+** ===================================================================
+** drawText()
+** Draws text on the display at a given position and font size.
+** ===================================================================
+*/
+void DisplayUI::drawText(const char *text, int posX, int posY, int fontSize, TFTColor color)
+{
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ _ofr->setFontSize(fontSize);
+ _ofr->setAlignment(Align::BottomLeft);
+ _tft->fillRect(posX - 70, posY, 140, fontSize + 10, toValue(TFTColor::Black));
+ _ofr->cdrawString(text, posX, posY, toRGB565(color), toRGB565(TFTColor::Black));
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** drawString()
+** Draws a string at the specified location with the given font,
+** foreground and background colors, alignment, and optional clear mask.
+** ===================================================================
+*/
+void DisplayUI::drawString(const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ Align alignment,
+ const char *clearMask)
+{
+ _ofr->setAlignment(alignment);
+ _ofr->setFontSize(fontSize);
+ uint16_t fgColor = toRGB565(fg);
+ uint16_t bgColor = toRGB565(bg);
+
+ // figure out what to clear
+ FT_BBox bbox;
+ if (strlen(clearMask) > 0)
+ {
+ // clear just the mask
+ bbox = _ofr->calculateBoundingBox(x, y, _ofr->getFontSize(), Align::Left, Layout::Horizontal, clearMask);
+ int width = bbox.xMax - bbox.xMin;
+ int height = bbox.yMax - bbox.yMin;
+ _tft->fillRect(x, bbox.yMin, width, height, toValue(bg));
+ }
+ else
+ {
+ // clear entire line
+ bbox = _ofr->calculateBoundingBox(x, y, _ofr->getFontSize(), Align::Left, Layout::Horizontal, "AG123jt");
+ int width = bbox.xMax - bbox.xMin;
+ int height = bbox.yMax - bbox.yMin;
+ _tft->fillRect(0, y, _tft->width(), height, toValue(bg));
+ }
+
+ _ofr->drawString(str, x, y, fgColor, bgColor, Layout::Horizontal);
+}
+
+/*
+** ===================================================================
+** cDrawString()
+** Draws a centered string with foreground and background color.
+** ===================================================================
+*/
+void DisplayUI::cDrawString( const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask)
+{
+ drawString(str, x, y, fontSize, fg, bg, Align::Center, clearMask);
+}
+
+/*
+** ===================================================================
+** lDrawString()
+** Draws a left-aligned string with foreground and background color.
+** ===================================================================
+*/
+void DisplayUI::lDrawString( const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask)
+{
+ drawString(str, x, y, fontSize, fg, bg, Align::Left, clearMask);
+}
+
+/*
+** ===================================================================
+** rDrawString()
+** Draws a right-aligned string with foreground and background color.
+** ===================================================================
+*/
+void DisplayUI::rDrawString( const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask)
+{
+ _ofr->setAlignment(Align::Right);
+ _ofr->setFontSize(fontSize);
+ uint16_t fgColor = toRGB565(fg);
+ uint16_t bgColor = toRGB565(bg);
+
+ // figure out what to clear
+ FT_BBox bbox = _ofr->calculateBoundingBox(x, y, _ofr->getFontSize(), Align::Right, Layout::Horizontal, clearMask);
+ int width = bbox.xMax - bbox.xMin;
+ int height = bbox.yMax - bbox.yMin;
+ _tft->fillRect(x - width, bbox.yMin, width, height, toValue(bg));
+
+ _ofr->drawString(str, x, y, fgColor, bgColor, Layout::Horizontal);
+}
+
+/*
+** ===================================================================
+** drawClockTime()
+** Draws the current time using clock font and updates only when values change.
+** ===================================================================
+*/
+void DisplayUI::drawClockTime(bool isUSFormat, bool isForceRepaint)
+{
+ const int fontSize = 128;
+ const int fontWidth = 76;
+
+ int offset = 0;
+ std::string hours = "";
+ if (isUSFormat)
+ {
+ hours = getCurrentTimestamp("%l").c_str();
+ }
+ else
+ {
+ hours = getCurrentTimestamp("%H").c_str();
+ offset = 50;
+ }
+ std::string minutes = getCurrentTimestamp("%M").c_str();
+ std::string ampm = getCurrentTimestamp("%p").c_str();
+ std::string seconds = getCurrentTimestamp("%S").c_str();
+
+ static std::string lastHours = "";
+ static std::string lastMinutes = "";
+ static std::string lastAmpm = "";
+
+ // Hours
+ if ((lastHours != hours)
+ || isForceRepaint)
+ {
+ _tft->fillRect(20 + offset, 35, fontWidth * 2, 100, toValue(getBackground()));
+ drawClockString(hours.c_str(), 20 + offset, 25, fontSize, TFTColor::White, getBackground());
+ lastHours = hours;
+ }
+
+ // Seconds indicator :
+ static bool isLastColonVisible = true;
+ int iSeconds = std::stoi(seconds);
+ bool isCurrentColonVisible = (iSeconds % 2 == 0);
+ if (isCurrentColonVisible != isLastColonVisible
+ || isForceRepaint)
+ {
+ _tft->fillRect(180 + offset, 53, 35, 84, toValue(getBackground()));
+ const char* colonChar = isCurrentColonVisible ? ":" : " ";
+ drawClockString(colonChar, 160 + offset, 20, fontSize, TFTColor::Yellow, getBackground());
+ isLastColonVisible = isCurrentColonVisible;
+ }
+
+ // Minutes
+ if ((lastMinutes != minutes)
+ || isForceRepaint)
+ {
+ _tft->fillRect(225 + offset, 35, fontWidth * 2, 100, toValue(getBackground()));
+ drawClockString(minutes.c_str(), 220 + offset, 25, fontSize, TFTColor::White, getBackground());
+ lastMinutes = minutes;
+ }
+
+ // AM/PM if US Format
+ if (isUSFormat)
+ {
+ if ((lastAmpm != ampm)
+ || isForceRepaint)
+ {
+ _tft->fillRect(380, 40, fontWidth, 100, toValue(getBackground())); //toValue(getBackground()));
+ if (ampm == "AM")
+ {
+ drawClockString(ampm.c_str(), 380, 35, fontSize / 2, TFTColor::Yellow, getBackground());
+ }
+ else
+ {
+ drawClockString(ampm.c_str(), 380, 80, fontSize / 2, TFTColor::Yellow, getBackground());
+ }
+
+ lastAmpm = ampm;
+ }
+ }
+}
+
+/*
+** ===================================================================
+** drawClockString()
+** Draws a string using the clock font renderer with a specified
+** foreground and background color at a given position.
+**
+** Parameters:
+** str - The string to draw.
+** x - X-coordinate on the screen.
+** y - Y-coordinate on the screen.
+** fontSize - Font size to use for rendering.
+** fg - Foreground color (text color).
+** bg - Background color.
+**
+** Notes:
+** - This method does not clear the area before drawing. The caller
+** must clear the area manually if needed.
+** - Intended for use in clock display rendering.
+**
+** Example Usage:
+** drawClockString("12", 30, 30, 96, TFTColor::Yellow, TFTColor::Black);
+** ===================================================================
+*/
+void DisplayUI::drawClockString(
+ const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg)
+{
+ _clockFont->setAlignment(Align::Left);
+ _clockFont->setFontSize(fontSize);
+ _clockFont->drawString(str, x, y, toRGB565(fg), toRGB565(bg), Layout::Horizontal);
+}
+
+/*
+** ===================================================================
+** formatTime()
+** Converts a time duration from milliseconds to a string in the
+** format HH:MM:SS or MM:SS if hours are zero.
+**
+** Parameters:
+** millis - Time duration in milliseconds.
+**
+** Returns:
+** A string representing the time:
+** - "H:MM:SS" if hours > 0
+** - "M:SS" if hours == 0
+**
+** Notes:
+** - Removes leading zeros (e.g., "1:05" instead of "01:05").
+** - Skips hours if they are zero (e.g., "5:30" instead of "00:05:30").
+**
+** Example Usage:
+** Serial.println(formatTime(3661000)); // Output: "1:01:01"
+** Serial.println(formatTime(59000)); // Output: "59"
+** Serial.println(formatTime(3600000)); // Output: "1:00:00"
+** Serial.println(formatTime(300000)); // Output: "5:00"
+** ===================================================================
+*/
+std::string DisplayUI::formatTime(long millis) {
+ long totalSeconds = millis / 1000;
+ int hours = totalSeconds / 3600;
+ int minutes = (totalSeconds % 3600) / 60;
+ int seconds = totalSeconds % 60;
+
+ std::string result;
+
+ if (hours > 0)
+ {
+ result += std::to_string(hours) + ":";
+ }
+
+ result += std::to_string(minutes) + ":"; // No leading zero for minutes
+ if (seconds < 10)
+ {
+ result += "0"; // Ensure seconds always have two digits
+ }
+ result += std::to_string(seconds);
+
+ return result;
+}
+
+
+/*
+** ===================================================================
+** showTouchDown()
+** Highlights UI borders in the specified color to indicate a touch-down event.
+** ===================================================================
+*/
+void DisplayUI::showTouchDown(TFTColor color)
+{
+ paintTouch(true, color);
+}
+
+/*
+** ===================================================================
+** showTouchUp()
+** Clears UI border highlights to indicate a touch-up event.
+** ===================================================================
+*/
+void DisplayUI::showTouchUp()
+{
+ paintTouch(false, getBackground());
+}
+
+/*
+** ===================================================================
+** paintTouch()
+** Helper method to paint or clear UI border highlights based on touch state.
+** ===================================================================
+*/
+void DisplayUI::paintTouch(bool isPress, TFTColor color)
+{
+ const int32_t lineSize = 10;
+
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ // Top
+ _tft->fillRect(0, 0, _tft->width() , lineSize, toValue(color));
+
+ if (!isSplitBackground())
+ {
+ // Left
+ _tft->fillRect(0, lineSize, lineSize, _tft->height(), toValue(color));
+
+ // Right
+ _tft->fillRect(_tft->width() - lineSize, lineSize, lineSize, _tft->height(), toValue(color));
+
+ // Bottom
+ _tft->fillRect(0, _tft->height() - lineSize, _tft->width() , lineSize, toValue(color));
+ }
+
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+
+}
+
+/*
+** ===================================================================
+** getPreviousActionPoint()
+** top third of the display, left of the cover art
+** ===================================================================
+*/
+Point DisplayUI::getPreviousActionPoint()
+{
+ // top third of the display, left of the cover art
+ int x = (_tft->width() - COVER_ART_SIZE) / 2;
+ int y = (_tft->height()) / 3;
+ return Point(x,y);
+}
+
+/*
+** ===================================================================
+** getNextActionPoint()
+** top third of the display, right of the cover art
+** ===================================================================
+*/
+Point DisplayUI::getNextActionPoint()
+{
+ int x = _tft->width() - ((_tft->width() - COVER_ART_SIZE) / 2);
+ int y = (_tft->height()) / 3;
+ return Point(x,y);
+}
+
+/*
+** ===================================================================
+** pushImageToTft()
+**
+** Function will be called as a callback during decoding of a JPEG file to
+** render each block to the TFT.
+** ===================================================================
+*/
+
+bool DisplayUI::pushImageToTft(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap) {
+ // Stop further decoding as image is running off bottom of screen
+ if (y >= _tft->height()) {
+ return 0;
+ }
+
+ // Automatically clips the image block rendering at the TFT boundaries.
+ _tft->pushImage(x, y, w, h, bitmap);
+
+ // Return 1 to decode next block
+ return 1;
+}
+
+/*
+** ===================================================================
+** setJpgScaleToSmall()
+**
+** Adjusts the JPEG decoder scaling factor to either full scale (1:1)
+** or half scale (1/2) based on the provided flag. Ensures thread
+** safety by using a semaphore to coordinate access to the display.
+**
+** Parameters:
+** isSmall - If true, sets the decoder to half scale (80x80 resolution).
+** If false, sets the decoder to full scale (160x160 resolution).
+**
+** Notes:
+** - Uses `xSemaphoreDisplay` to ensure exclusive access to the display.
+** - If the semaphore cannot be acquired, a log message is generated.
+** - JPEG scaling is controlled using `TJpgDec.setJpgScale()`.
+**
+** ===================================================================
+*/
+void DisplayUI::setJpgScaleToSmall(bool isSmall)
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ if (isSmall)
+ {
+ TJpgDec.setJpgScale(2); // half scale
+ }
+ else
+ {
+ TJpgDec.setJpgScale(1); // full scale
+ }
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+}
+
+/*
+** ===================================================================
+** setJpgScaleToTiny()
+** Sets the JPEG decoder to use 1/8th scaling for rendering.
+** ===================================================================
+*/
+void DisplayUI::setJpgScaleToTiny()
+{
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ TJpgDec.setJpgScale(8); // 1/8th scale
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+}
+
+/*
+** ===================================================================
+** drawFsJpg()
+**
+** Draws a JPEG image from the filesystem to the display at a
+** specified location. Ensures thread safety by using a semaphore
+** to coordinate access to the display. Returns a result code
+** indicating success or failure.
+**
+** Parameters:
+** x - X-coordinate on the display where the image will be drawn.
+** y - Y-coordinate on the display where the image will be drawn.
+** pFilename - Path to the JPEG file in the filesystem.
+** fs - Filesystem instance (e.g., LittleFS, SPIFFS) containing
+** the image file.
+**
+** Returns:
+** JRESULT - JPEG decoding result code (e.g., JDR_OK for success).
+**
+** Notes:
+** - If the file does not exist, the method logs an error and returns
+** a parameter error code (JDR_PAR).
+** - If the semaphore cannot be taken, the method logs a message and
+** skips the drawing operation.
+** - The method is thread-safe due to semaphore protection.
+**
+** ===================================================================
+*/
+
+JRESULT DisplayUI::drawFsJpg(int32_t x, int32_t y, const char *pFilename, fs::FS &fs)
+{
+ JRESULT result = JDR_PAR; // Default to parametere error
+
+ if (LittleFS.exists(pFilename))
+ {
+ if (xSemaphoreTake(xSemaphoreDisplay, portMAX_DELAY))
+ {
+ result = TJpgDec.drawFsJpg(x, y, pFilename, fs);
+ xSemaphoreGive(xSemaphoreDisplay);
+ }
+ else
+ {
+ spLogI(LOGTAG_MULTITASK,"Unable to take xSemaphoreDisplay.");
+ }
+ }
+ else
+ {
+ spLogE(LOGTAG_MULTITASK,"Filename does not exist.");
+ }
+
+ return result;
+
+}
+
+/*
+** ===================================================================
+** jpgCallback()
+** JPEG decoder callback to render or analyze JPEG pixels.
+** ===================================================================
+*/
+bool DisplayUI::jpgCallback(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap)
+{
+ // Implementation for handling the callback
+ bool result = false;
+ if (pInstance->_isPainting)
+ {
+ result = pInstance->pushImageToTft(x, y, w, h, bitmap);
+ }
+ else
+ {
+ result = pInstance->jpgCalculateAverageColorCallback(x, y, w, h, bitmap);
+ }
+
+ return result; // Placeholder
+}
+
+
+/*
+** ===================================================================
+** Helper Function: fromRGB()
+** Converts 8-bit R, G, B values to a 16-bit RGB565 TFTColor.
+** ===================================================================
+*/
+constexpr TFTColor fromRGB(uint8_t red, uint8_t green, uint8_t blue) {
+ return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3));
+}
+
+// Globals for color calculation
+static uint32_t totalR = 0, totalG = 0, totalB = 0;
+static uint32_t pixelCount = 0;
+
+/*
+** ===================================================================
+** jpgCalculateAverageColorCallback()
+** Callback used during JPEG decoding to compute the average color
+** of the image for background styling or theme adaptation.
+** ===================================================================
+*/
+
+// Callback function for pixel processing
+bool DisplayUI::jpgCalculateAverageColorCallback(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t* bitmap)
+{
+
+ // Sampling factor (e.g., process every 4th pixel)
+
+ const uint16_t samplingFactor = 4; // 16; //4;
+
+ for (uint16_t i = 0; i < w * h; i += samplingFactor)
+ {
+ uint16_t pixel = bitmap[i];
+
+ uint8_t r8 = (pixel >> 11) & 0x1F; // 5-bit R
+ uint8_t g8 = (pixel >> 5) & 0x3F; // 6-bit G
+ uint8_t b8 = pixel & 0x1F; // 5-bit B
+
+ // Scale up to 8-bit
+ uint8_t r = r8 << 3;
+ uint8_t g = g8 << 2;
+ uint8_t b = b8 << 3;
+
+ int brightness = (r + g + b) / 3;
+
+ // Skip extremely bright or extremely dark
+ if (brightness < 240
+ && brightness > 20)
+ {
+
+ totalR += r;
+ totalG += g;
+ totalB += b;
+ pixelCount++;
+ }
+ }
+
+ return true; // Continue processing
+
+}
+
+/*
+** ===================================================================
+** calculateAverageColor()
+** ===================================================================
+*/
+// Calculate average color of JPEG file
+TFTColor DisplayUI::calculateAverageColor(const char* filename)
+{
+ totalR = 0;
+ totalG = 0;
+ totalB = 0;
+ pixelCount = 0;
+
+ spLogV(LOGTAG_PLAYER, "Entering calculateAverageColor(%s)", filename);
+
+ Monitor::start(MONITOR_ID_CALCULATE_AVG_BACKGROUND_COLOR, LOGTAG_METRICS, "- drawFsJpg(0, 0, filename, LittleFS)");
+ _isPainting = false; // TODO: wrap to make thread safe... maybe this block until it is reset.
+ drawFsJpg(0, 0, filename, LittleFS);
+ _isPainting = true;
+ spLogV(LOGTAG_PLAYER,"decoder.drawFsJpg() result is %d",filename);
+ Monitor::stop(MONITOR_ID_CALCULATE_AVG_BACKGROUND_COLOR);
+
+ if (pixelCount == 0) {
+ spLogE(LOGTAG_SONG_DATA, "No pixels processed from the image.");
+ return TFTColor::Black;
+ }
+
+ uint8_t avgR = totalR / pixelCount;
+ uint8_t avgG = totalG / pixelCount;
+ uint8_t avgB = totalB / pixelCount;
+
+ spLogV(LOGTAG_PLAYER, "Average color: R=%d, G=%d, B=%d", avgR, avgG, avgB);
+ spLogV(LOGTAG_PLAYER, "pixelCount = %d", pixelCount);
+
+ // Convert to HSV, boost saturation, convert back ---
+ float h, s, v;
+ DisplayUI::rgbToHsv(avgR, avgG, avgB, h, s, v);
+
+ // Increase saturation (example: 1.5x)
+ s = std::min(s * 1.5f, 1.0f); // clamp at 1.0
+
+ // Convert back to RGB
+ uint8_t finalR, finalG, finalB;
+ DisplayUI::hsvToRgb(h, s, v, finalR, finalG, finalB);
+
+ spLogV(LOGTAG_PLAYER, "Boosted color: R=%d, G=%d, B=%d", finalR, finalG, finalB);
+
+ // Convert to 565
+ TFTColor answer = fromRGB(finalR, finalG, finalB);
+
+ return answer;
+}
+
+/*
+** ===================================================================
+** rgbToHsv()
+** Converts an RGB color value to HSV (Hue, Saturation, Value).
+** This function modifies the hue, saturation, and value floats
+** passed in by reference.
+**
+** Parameters:
+** r - The red component in [0..255].
+** g - The green component in [0..255].
+** b - The blue component in [0..255].
+** h - Output for hue in degrees [0..360).
+** s - Output for saturation in [0..1].
+** v - Output for value (brightness) in [0..1].
+**
+** Returns:
+** None. The results are written to h, s, and v by reference.
+** ===================================================================
+*/
+void DisplayUI::rgbToHsv(uint8_t r, uint8_t g, uint8_t b, float& h, float& s, float& v)
+{
+ // Convert from 8-bit [0..255] to floating-point [0..1]
+ float fr = r / 255.0f;
+ float fg = g / 255.0f;
+ float fb = b / 255.0f;
+
+ // Find the maximum and minimum among R, G, B
+ float maxVal = std::max({fr, fg, fb});
+ float minVal = std::min({fr, fg, fb});
+ float delta = maxVal - minVal; // Range between max and min
+
+ // "Value" (brightness) in HSV is simply the maximum component
+ v = maxVal;
+
+ // If delta is extremely small, color is essentially grayscale
+ if (delta < 0.00001f)
+ {
+ s = 0.0f; // No saturation
+ h = 0.0f; // Hue is undefined, treat as 0
+ return;
+ }
+
+ // Saturation is the ratio of the color range to the brightness
+ s = (maxVal > 0.0f) ? (delta / maxVal) : 0.0f;
+
+ // Determine hue based on which channel is the maximum
+ if (maxVal == fr)
+ {
+ // If red is max, hue depends on (green - blue) relative to delta
+ h = 60.0f * fmod(((fg - fb) / delta), 6.0f);
+ // Wrap negative angles into the [0..360) range
+ if (h < 0.0f)
+ {
+ h += 360.0f;
+ }
+ }
+ else if (maxVal == fg)
+ {
+ // If green is max, hue is in the 120..180 range
+ h = 60.0f * (((fb - fr) / delta) + 2.0f);
+ }
+ else // maxVal == fb
+ {
+ // If blue is max, hue is in the 240..300 range
+ h = 60.0f * (((fr - fg) / delta) + 4.0f);
+ }
+}
+
+/*
+** ===================================================================
+** hsvToRgb()
+** Converts an HSV (Hue, Saturation, Value) color value to RGB.
+** This function modifies the red, green, and blue bytes passed
+** in by reference.
+**
+** Parameters:
+** h - The hue in degrees [0..360).
+** s - The saturation in [0..1].
+** v - The value (brightness) in [0..1].
+** r - Output for red in [0..255].
+** g - Output for green in [0..255].
+** b - Output for blue in [0..255].
+**
+** Returns:
+** None. The results are written to r, g, and b by reference.
+** ===================================================================
+*/
+void DisplayUI::hsvToRgb(float h, float s, float v, uint8_t& r, uint8_t& g, uint8_t& b)
+{
+ // c is the "chroma"—the intensity of the color (range [0..v])
+ float c = v * s;
+
+ // x is an intermediate value used to spread out the color
+ // based on how far H is into each 60-degree segment
+ float x = c * (1.0f - fabs(fmod(h / 60.0f, 2.0f) - 1.0f));
+
+ // m adjusts the final values to match the requested Value (v)
+ float m = v - c;
+
+ // Temporary floats for the "pure" R, G, B before adding m
+ float fr = 0;
+ float fg = 0;
+ float fb = 0;
+
+ // Determine which 60-degree segment of the 360-degree Hue range we're in
+ if (h < 60)
+ {
+ // Segment 0: Red is at full chroma, green is partial, blue is 0
+ fr = c;
+ fg = x;
+ fb = 0;
+ }
+ else if (h < 120)
+ {
+ // Segment 1: Green is at full chroma, red is partial, blue is 0
+ fr = x;
+ fg = c;
+ fb = 0;
+ }
+ else if (h < 180)
+ {
+ // Segment 2: Green is at full chroma, blue is partial, red is 0
+ fr = 0;
+ fg = c;
+ fb = x;
+ }
+ else if (h < 240)
+ {
+ // Segment 3: Blue is at full chroma, green is partial, red is 0
+ fr = 0;
+ fg = x;
+ fb = c;
+ }
+ else if (h < 300)
+ {
+ // Segment 4: Blue is at full chroma, red is partial, green is 0
+ fr = x;
+ fg = 0;
+ fb = c;
+ }
+ else
+ {
+ // Segment 5: Red is at full chroma, blue is partial, green is 0
+ fr = c;
+ fg = 0;
+ fb = x;
+ }
+
+ // Convert the float values [0..1] into 8-bit [0..255] range
+ // and add m to each channel to match the intended brightness (Value)
+ r = static_cast((fr + m) * 255);
+ g = static_cast((fg + m) * 255);
+ b = static_cast((fb + m) * 255);
+}
diff --git a/src/DisplayUI.h b/src/DisplayUI.h
new file mode 100644
index 0000000..da5e18e
--- /dev/null
+++ b/src/DisplayUI.h
@@ -0,0 +1,215 @@
+/*-------------------------------------------------------------------------------------------------
+**
+** DisplayUI.h
+**
+** Provides methods for drawing UI elements including album art, buttons,
+** progress indicators, and text, using the TFT_eSPI and OpenFontRender libraries.
+**
+** Originally based on https://github.com/Bodmer/OpenWeather/blob/main/examples/TFT_eSPI_OpenWeather_LittleFS/GfxUi.h
+**
+** SPDX-FileCopyrightText: 2025 ThingPulse Ltd., https://thingpulse.com
+** SPDX-License-Identifier: MIT
+**
+** ------------------------------------------------------------------------------------------------
+** Change Log:
+** 2024-12-26 - Electric Diversions - Renamed from GfxUI.h.h to tpGfxUi.h
+** 2024-12-27 - Electric Diversions - Renamed to DisplayUI and promoted to base class
+** ------------------------------------------------------------------------------------------------
+*/
+
+#pragma once
+
+#include
+#include
+#include
+
+// JPEG decoder library
+#include
+
+#include
+
+#include "Point.h"
+
+
+// Maximum of 85 for BUFFPIXEL as 3 x this value is stored in an 8 bit variable!
+// 32 is an efficient size for LittleFS due to SPI hardware pipeline buffer size
+// A larger value of 80 is better for SD cards
+#define BUFFPIXEL 32
+
+// Size of cover art square side.
+#define COVER_ART_SIZE 300
+
+/*
+** ===================================================================
+** Predefined colors to use on the UI
+** ===================================================================
+*/
+enum class TFTColor : uint16_t
+{
+ Black = 0x0000, // 0, 0, 0
+ Blue = 0x001F, // 0, 0, 255
+ BlueThinkPulse = 0x0336, // The medium blue in the TP logo is 0x0067B0 which converts to 0x0336 in 16bit RGB565
+ Brown = 0x9A60, // 150, 75, 0
+ Cyan = 0x07FF, // 0, 255, 255
+ DarkCyan = 0x03EF, // 0, 128, 128
+ DarkGreen = 0x03E0, // 0, 128, 0
+ DarkestGreen = 0x0280,
+ DarkGrey = 0x7BEF, // 128, 128, 128
+ Gold = 0xFEA0, // 255, 215, 0
+ Green = 0x07E0, // 0, 255, 0
+ GreenYellow = 0xB7E0, // 180, 255, 0
+ LightGrey = 0xD69A, // 211, 211, 211
+ LightPink = 0xFC9F, //
+ Magenta = 0xF81F, // 255, 0, 255
+ Maroon = 0x7800, // 128, 0, 0
+ Navy = 0x000F, // 0, 0, 128
+ Olive = 0x7BE0, // 128, 128, 0
+ Orange = 0xFDA0, // 255, 180, 0
+ Pink = 0xFE19, // 255, 192, 203
+ Purple = 0x780F, // 128, 0, 128
+ Red = 0xF800, // 255, 0, 0
+ Silver = 0xC618, // 192, 192, 192
+ SkyBlue = 0x867D, // 135, 206, 235
+ Violet = 0x915C, // 180, 46, 226
+ White = 0xFFFF, // 255, 255, 255
+ Yellow = 0xFFE0, // 255, 255, 0
+
+ SC_LowHeap = 0xAB04, // 174, 98, 37
+ SC_DJX_BG = 0x22D6, // 39, 88, 178
+ SC_DJX_FG = 0x6F11, // 108, 227, 148
+ SC_PreviousTrack = 0xFEA0, // 255, 215, 0
+ SC_NextTrack = 0xB7E0, // 180, 255, 0
+ SC_PauseTrack = 0x780F, // 128, 0, 128
+ SC_CacheSave = 0x867D, // 135, 206, 235
+ SC_TouchDown = 0x867D, // 135, 206, 235
+ SC_AlertStatus = 0xFFE0, // 255, 255, 0
+ SC_NetworkInProgress = 0x7BEF, // 128, 128, 128
+ SC_NetworkSuccess = 0x03E0, // 0, 128, 0
+ SC_NetworkFailure = 0xF800 // 255, 0, 0
+};
+
+class DisplayUI {
+public:
+ DisplayUI(TFT_eSPI *tft, OpenFontRender *render, OpenFontRender *clockFont);
+ void init();
+
+ // Original Thing Pulse methods
+ void drawBmp(String filename, uint16_t x, uint16_t y);
+ void drawLogo();
+ void drawAppInfo();
+ void drawProgressBar(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
+ uint8_t percentage, TFTColor frameColor,
+ TFTColor barColor);
+ // Added 12/2024:
+ void drawAlbumArt(int32_t x, int32_t y, String filename);
+ void setJpgScaleToSmall(bool isSmall);
+ void setJpgScaleToTiny();
+ void drawStatusBox(TFTColor statusColor);
+ void drawDownloadIndicator(bool bDownloadComplate);
+ void drawProgress(const char *text, int8_t percentage);
+ void drawSeparator(uint16_t y);
+ void drawButton(int32_t x, int32_t y, bool isPressed);
+ void drawBlankButton(int32_t x, int32_t y, int32_t width, int32_t height, uint8_t margin, TFTColor borderColor, bool isPressed);
+ void drawSkipTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height, bool isReversed);
+ void drawPlayTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height);
+ void drawPauseTrackIcon(int32_t x, int32_t y, int32_t width, int32_t height);
+ void drawBackIcon(int32_t x, int32_t y, int32_t width, int32_t height);
+
+ void setBackground(TFTColor color, bool bRepaint=true);
+ TFTColor getBackground() const;
+ bool isSplitBackground() const;
+ void setSplitBackground(bool isSplitBackground = false);
+ void clearScreen();
+ void clearScreenKeepArt();
+ void clearScreenHome();
+ std::string formatTime(long millis);
+
+ void drawTextToLCD(const char *text, int posY);
+ void drawTextToLCD(const char *text, int posY, int fontSize, bool ignoreText);
+ void drawText(const char *text, int posX, int posY, int fontSize, TFTColor color);
+
+ void drawString(const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ Align alignment,
+ const char *clearMask);
+
+ void cDrawString(const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask);
+
+ void lDrawString(const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask);
+
+ void rDrawString(const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg,
+ const char *clearMask);
+
+ void drawClockTime(bool isUSFormat, bool isForceRepaint);
+
+ void showTouchDown(TFTColor color = TFTColor::SC_TouchDown);
+ void showTouchUp();
+
+ bool pushImageToTft(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap);
+
+ // display properties
+ int16_t getCenterWidth();
+ int16_t getCenterHeight();
+ int16_t getWidth();
+ int16_t getHeight();
+ Point getPreviousActionPoint();
+ Point getNextActionPoint();
+
+ bool isUIDirty();
+ void markUIDirty(bool isDirty);
+
+ JRESULT drawFsJpg(int32_t x, int32_t y, const char *pFilename, fs::FS &fs);
+ TFTColor calculateAverageColor(const char* filename);
+
+ // utility methods
+ static void rgbToHsv(uint8_t r, uint8_t g, uint8_t b, float& h, float& s, float& v);
+ static void hsvToRgb(float h, float s, float v, uint8_t& r, uint8_t& g, uint8_t& b);
+
+
+private:
+ // Member variables
+ TFT_eSPI *_tft;
+ OpenFontRender *_ofr;
+ OpenFontRender *_clockFont;
+ TFTColor _bgColor = TFTColor::BlueThinkPulse;
+ bool _isSplitBackground = false;
+ bool _isUIDirty = false;
+ bool _isPainting = true;
+ SemaphoreHandle_t xSemaphoreDisplay = xSemaphoreCreateMutex();
+
+ // Methods
+ uint16_t read16(fs::File &f);
+ uint32_t read32(fs::File &f);
+ void paintTouch(bool isPress, TFTColor color);
+ static bool jpgCallback(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap);
+ bool jpgCalculateAverageColorCallback(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t* bitmap);
+ void drawClockString(
+ const char *str,
+ int32_t x,
+ int32_t y,
+ unsigned int fontSize,
+ TFTColor fg,
+ TFTColor bg);
+
+};
diff --git a/src/FT6236.cpp b/src/FT6236TouchController/FT6236.cpp
similarity index 100%
rename from src/FT6236.cpp
rename to src/FT6236TouchController/FT6236.cpp
diff --git a/src/FT6236.h b/src/FT6236TouchController/FT6236.h
similarity index 100%
rename from src/FT6236.h
rename to src/FT6236TouchController/FT6236.h
diff --git a/src/GfxUi.cpp b/src/GfxUi.cpp
deleted file mode 100644
index 4b4618d..0000000
--- a/src/GfxUi.cpp
+++ /dev/null
@@ -1,121 +0,0 @@
-// SPDX-FileCopyrightText: 2023 ThingPulse Ltd., https://thingpulse.com
-// SPDX-License-Identifier: MIT
-
-// Based on https://github.com/Bodmer/OpenWeather/blob/main/examples/TFT_eSPI_OpenWeather_LittleFS/GfxUi.cpp
-
-#include "GfxUi.h"
-
-#define FS_TP_LOGO "/ThingPulse-logo-260.jpeg"
-
-GfxUi::GfxUi(TFT_eSPI *tft, OpenFontRender *ofr) {
- _tft = tft;
- _ofr = ofr;
-}
-
-// Bodmer's streamlined x2 faster "no seek" version
-void GfxUi::drawBmp(String filename, uint16_t x, uint16_t y) {
-
- if ((x >= _tft->width()) || (y >= _tft->height()))
- return;
-
- fs::File bmpFS;
-
- // Note: ESP32 passes "open" test even if file does not exist, whereas ESP8266
- // returns NULL
- if (!LittleFS.exists(filename)) {
- log_e(" File not found");
- return;
- }
-
- // Open requested file
- bmpFS = LittleFS.open(filename, "r");
-
- uint32_t seekOffset;
- uint16_t w, h, row;
- uint8_t r, g, b;
- bool oldSwap = false;
-
- if (read16(bmpFS) == 0x4D42) {
- read32(bmpFS);
- read32(bmpFS);
- seekOffset = read32(bmpFS);
- read32(bmpFS);
- w = read32(bmpFS);
- h = read32(bmpFS);
-
- if ((read16(bmpFS) == 1) && (read16(bmpFS) == 24) && (read32(bmpFS) == 0)) {
- y += h - 1;
-
- oldSwap = _tft->getSwapBytes();
- _tft->setSwapBytes(true);
- bmpFS.seek(seekOffset);
-
- // Calculate padding to avoid seek
- uint16_t padding = (4 - ((w * 3) & 3)) & 3;
- uint8_t lineBuffer[w * 3 + padding];
-
- for (row = 0; row < h; row++) {
-
- bmpFS.read(lineBuffer, sizeof(lineBuffer));
- uint8_t *bptr = lineBuffer;
- uint16_t *tptr = (uint16_t *)lineBuffer;
- // Convert 24 to 16 bit colours using the same line buffer for results
- for (uint16_t col = 0; col < w; col++) {
- b = *bptr++;
- g = *bptr++;
- r = *bptr++;
- *tptr++ = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
- }
-
- // Push the pixel row to screen, pushImage will crop the line if needed
- // y is decremented as the BMP image is drawn bottom up
- _tft->pushImage(x, y--, w, 1, (uint16_t *)lineBuffer);
- }
- } else
- log_e("BMP format not recognized.");
- }
- _tft->setSwapBytes(oldSwap);
- bmpFS.close();
-}
-
-void GfxUi::drawLogo() {
- if (LittleFS.exists(FS_TP_LOGO)) {
- uint16_t w = 0, h = 0;
- TJpgDec.getFsJpgSize(&w, &h, FS_TP_LOGO, LittleFS);
- TJpgDec.drawFsJpg((_tft->width() - w) / 2, 30, FS_TP_LOGO, LittleFS);
- }
-}
-
-void GfxUi::drawProgressBar(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h,
- uint8_t percentage, uint16_t frameColor,
- uint16_t barColor) {
- if (percentage == 0) {
- _tft->fillRoundRect(x0, y0, w, h, 3, TFT_BLACK);
- }
- uint8_t margin = 2;
- uint16_t barHeight = h - 2 * margin;
- uint16_t barWidth = w - 2 * margin;
- _tft->drawRoundRect(x0, y0, w, h, 3, frameColor);
- _tft->fillRect(x0 + margin, y0 + margin, barWidth * percentage / 100.0,
- barHeight, barColor);
-}
-
-// These read 16- and 32-bit types from the SD card file.
-// BMP data is stored little-endian, Arduino is little-endian too.
-// May need to reverse subscript order if porting elsewhere.
-
-uint16_t GfxUi::read16(fs::File &f) {
- uint16_t result;
- ((uint8_t *)&result)[0] = f.read(); // LSB
- ((uint8_t *)&result)[1] = f.read(); // MSB
- return result;
-}
-
-uint32_t GfxUi::read32(fs::File &f) {
- uint32_t result;
- ((uint8_t *)&result)[0] = f.read(); // LSB
- ((uint8_t *)&result)[1] = f.read();
- ((uint8_t *)&result)[2] = f.read();
- ((uint8_t *)&result)[3] = f.read(); // MSB
- return result;
-}
diff --git a/src/GfxUi.h b/src/GfxUi.h
deleted file mode 100644
index ef86b30..0000000
--- a/src/GfxUi.h
+++ /dev/null
@@ -1,35 +0,0 @@
-// SPDX-FileCopyrightText: 2023 ThingPulse Ltd., https://thingpulse.com
-// SPDX-License-Identifier: MIT
-
-// Based on https://github.com/Bodmer/OpenWeather/blob/main/examples/TFT_eSPI_OpenWeather_LittleFS/GfxUi.h
-
-#pragma once
-
-#include
-#include
-#include
-#include
-
-// JPEG decoder library
-#include
-
-// Maximum of 85 for BUFFPIXEL as 3 x this value is stored in an 8 bit variable!
-// 32 is an efficient size for LittleFS due to SPI hardware pipeline buffer size
-// A larger value of 80 is better for SD cards
-#define BUFFPIXEL 32
-
-class GfxUi {
-public:
- GfxUi(TFT_eSPI *tft, OpenFontRender *render);
- void drawBmp(String filename, uint16_t x, uint16_t y);
- void drawLogo();
- void drawProgressBar(uint16_t x, uint16_t y, uint16_t w, uint16_t h,
- uint8_t percentage, uint16_t frameColor,
- uint16_t barColor);
-
-private:
- TFT_eSPI *_tft;
- OpenFontRender *_ofr;
- uint16_t read16(fs::File &f);
- uint32_t read32(fs::File &f);
-};
diff --git a/src/Monitor.cpp b/src/Monitor.cpp
new file mode 100644
index 0000000..0782d53
--- /dev/null
+++ b/src/Monitor.cpp
@@ -0,0 +1,441 @@
+/*-------------------------------------------------------------------------------------------------
+**
+** Monitor.cpp
+**
+** Utility class for performance and resource monitoring. Provides timing
+** instrumentation, heap tracking, and queue depth monitoring using static
+** methods and internal tracking structures.
+**
+** SPDX-FileCopyrightText: 2025 ThingPulse Ltd., https://thingpulse.com
+** SPDX-License-Identifier: MIT
+**
+** ------------------------------------------------------------------------------------------------
+** Change Log:
+** 2025-01-07 - Electric Diversions - Initial creation.
+** ------------------------------------------------------------------------------------------------
+*/
+
+#include "Monitor.h"
+#include "SCLogger.h"
+#include "logTags.h"
+#include
+
+// Initialize the static timers map
+std::map Monitor::timers;
+
+// Initialize id descriptors
+std::map Monitor::idDescriptions;
+
+// Initialize the static HeapStats struct
+Monitor::HeapStats Monitor::heapStats;
+
+
+uint32_t Monitor::maxQueueDepth = 0; // Max queue depth
+
+/*
+** ===================================================================
+** registerDescription()
+** Associates a human-readable description with a monitor ID.
+** This description is included in the statistics dump for better
+** clarity and context.
+**
+** Parameters:
+** id - Unique identifier for the timer.
+** description - A human-readable string describing the purpose
+** or context of the timer.
+**
+** Example Usage:
+** Monitor::registerDescription(MONITOR_ID_SPOTIFY_GET_CURRENTLY_PLAYING,
+** "Spotify: Get Currently Playing");
+**
+** Notes:
+** - Descriptions should be registered early in the application's
+** lifecycle, such as during initialization.
+** - If a description is not registered for an ID, "Unknown" will
+** appear in the statistics dump for that ID.
+**
+** ===================================================================
+*/
+void Monitor::registerDescription(int id, const std::string& description)
+{
+ idDescriptions[id] = description;
+}
+
+/*
+** ===================================================================
+** start()
+** Starts the monitor for a given timer ID.
+**
+** Parameters:
+** id - Unique identifier for the timer
+** tag - Logging tag for the timer
+** msg - Message to log when starting the timer
+**
+** ===================================================================
+*/
+void Monitor::start(int id, const char* tag, const char* msg)
+{
+ TimerInfo& info = timers[id];
+ info.startTime = std::chrono::steady_clock::now();
+ info.tag = tag;
+
+ spLogV(tag, "Monitor started (ID: %d). %s", id, msg);
+}
+
+/*
+** ===================================================================
+** stop()
+** Stops the monitor for a given timer ID and logs the elapsed time.
+**
+** Parameters:
+** id - Unique identifier for the timer
+**
+** ===================================================================
+*/
+void Monitor::stop(int id)
+{
+ auto it = timers.find(id);
+ if (it != timers.end())
+ {
+ // Extract tag and start time
+ TimerInfo& info = it->second;
+ const std::string& tag = info.tag;
+ auto startTime = info.startTime;
+
+ // Calculate the elapsed time
+ auto endTime = std::chrono::steady_clock::now();
+ auto elapsed = std::chrono::duration_cast(endTime - startTime).count();
+
+ // Update statistics
+ info.totalDuration += elapsed;
+ info.stopCount++;
+ if (elapsed < info.minDuration) info.minDuration = elapsed;
+ if (elapsed > info.maxDuration) info.maxDuration = elapsed;
+
+ // Calculate the average duration
+ long long avgDuration = info.totalDuration / info.stopCount;
+
+ // Log the results
+ spLogV(tag.c_str(), "Monitor stopped (ID: %d). Elapsed: %lld ms. Avg: %lld ms. Min: %lld ms. Max: %lld ms.",
+ id, elapsed, avgDuration, info.minDuration, info.maxDuration);
+ }
+ else
+ {
+ spLogW(LOGTAG_GENERAL, "Monitor stop called for an unknown ID: %d", id);
+ }
+}
+
+/*
+** ===================================================================
+** dumpStats()
+** Logs a table of statistics for all currently tracked timers.
+**
+** Parameters:
+** tag - Logging tag for the summary log entry
+**
+** ===================================================================
+*/
+void Monitor::dumpStats(const char* tag)
+{
+ // Define column widths for alignment
+ constexpr int idWidth = 6; // ID column width
+ constexpr int descWidth = 20; // Description column width
+ constexpr int countWidth = 12; // Stop Count column width
+ constexpr int avgWidth = 10; // Avg(ms) column width
+ constexpr int minWidth = 7; // Min column width
+ constexpr int maxWidth = 7; // Max column width
+
+ spLogI(tag, "Monitor Statistics Dump:");
+ spLogI(tag, "---------------------------------------------------------------------------------");
+ spLogI(tag, "| %*s | %-*s | %*s | %*s | %*s | %*s |",
+ idWidth, "ID",
+ descWidth, "Description",
+ countWidth, "Stop Count",
+ avgWidth, "Avg(ms)",
+ minWidth, "Min",
+ maxWidth, "Max");
+ spLogI(tag, "---------------------------------------------------------------------------------");
+
+ for (const auto& entry : timers)
+ {
+ const int id = entry.first;
+ const TimerInfo& info = entry.second;
+
+ // Get the description from idDescriptions, or use "Unknown ID" if not found
+ const std::string& description = idDescriptions.count(id) ? idDescriptions.at(id) : "Unknown ID";
+
+ if (info.stopCount > 0)
+ {
+ long long avgDuration = info.totalDuration / info.stopCount;
+
+ spLogI(tag, "| %*d | %-*s | %*d | %*lld | %*lld | %*lld |",
+ idWidth, id,
+ descWidth, description.c_str(),
+ countWidth, info.stopCount,
+ avgWidth, avgDuration,
+ minWidth, info.minDuration,
+ maxWidth, info.maxDuration);
+ }
+ else
+ {
+ spLogI(tag, "| %*d | %-*s | %*d | %*s | %*s | %*s |",
+ idWidth, id,
+ descWidth, description.c_str(),
+ countWidth, info.stopCount,
+ avgWidth, "N/A",
+ minWidth, "N/A",
+ maxWidth, "N/A");
+ }
+ }
+
+ spLogI(tag, "---------------------------------------------------------------------------------");
+}
+
+/*
+** ===================================================================
+** getStats()
+** Retrieves statistics for a specific monitor ID.
+**
+** Parameters:
+** monitorID - The unique ID of the monitor.
+**
+** Returns:
+** MonitorStats struct containing:
+** - ID
+** - Description (if registered, otherwise "Unknown ID")
+** - Stop count
+** - Average duration (ms)
+** - Min duration (ms)
+** - Max duration (ms)
+**
+** Notes:
+** - If the monitor ID is not found, it returns default values.
+**
+** ===================================================================
+*/
+MonitorStats Monitor::getStats(int monitorID)
+{
+ MonitorStats stats = {monitorID, "Unknown ID", 0, 0, LLONG_MAX, 0};
+
+ auto it = timers.find(monitorID);
+ if (it != timers.end())
+ {
+ const TimerInfo& info = it->second;
+
+ stats.description = idDescriptions.count(monitorID) ? idDescriptions.at(monitorID) : "Unknown ID";
+ stats.stopCount = info.stopCount;
+ stats.minMs = info.minDuration;
+ stats.maxMs = info.maxDuration;
+
+ // Calculate the average only if stopCount > 0 to avoid division by zero
+ stats.averageMs = (info.stopCount > 0) ? (info.totalDuration / info.stopCount) : 0;
+ }
+
+ spLogV(LOGTAG_METRICS, "Returned ID: %s Avg: %lld", stats.description.c_str(),stats.averageMs);
+ return stats;
+}
+
+
+/*
+** ===================================================================
+** watchHeap()
+** Logs the heap size and alerts if it falls below a threshold.
+**
+** Parameters:
+** threshold - The heap size threshold to warn about
+**
+** Returns:
+** true if over the heap threshold, false is under it
+**
+** ===================================================================
+*/
+bool Monitor::watchHeap(uint32_t threshold)
+{
+ // Get the current heap size
+ uint32_t currentHeap = ESP.getFreeHeap();
+ bool bAnswer = true;
+
+ // Update stats
+ if (currentHeap < heapStats.minHeap)
+ {
+ heapStats.minHeap = currentHeap;
+ }
+
+ if (currentHeap > heapStats.maxHeap)
+ {
+ heapStats.maxHeap = currentHeap;
+ }
+
+ heapStats.totalHeap += currentHeap;
+ heapStats.sampleCount++;
+
+ uint32_t avgHeap = heapStats.sampleCount > 0 ? heapStats.totalHeap / heapStats.sampleCount : 0;
+
+ // Log the current heap size
+ spLogV(LOGTAG_HEAP," ");
+ spLogI(LOGTAG_HEAP, "Heap size: %u bytes. Avg: %u bytes. Min: %u bytes. Max: %u bytes.",
+ currentHeap, avgHeap, heapStats.minHeap, heapStats.maxHeap);
+ spLogV(LOGTAG_HEAP," ");
+
+ // Warn if the heap size is below the threshold
+ if (currentHeap < threshold)
+ {
+ spLogW(LOGTAG_HEAP, "Heap size below threshold! Current: %u bytes, Threshold: %u bytes.",
+ currentHeap, threshold);
+ bAnswer = false;
+ }
+ return bAnswer;
+}
+
+/*
+** ===================================================================
+** watchHeap()
+** Logs the heap size and alerts if it falls below a threshold.
+**
+** Parameters:
+** threshold - The heap size threshold to warn about
+**
+** Returns:
+** true if over the heap threshold, false is under it
+**
+** ===================================================================
+*/
+bool Monitor::watchQueue(QueueHandle_t queueHandle, uint32_t threshold)
+{
+ // Get the current queue depth
+ UBaseType_t messagesInQueue = uxQueueMessagesWaiting(queueHandle);
+ bool bAnswer = false;
+
+ if (messagesInQueue > threshold)
+ {
+ spLogV(LOGTAG_GENERAL, "*** queue count over threshold of %u. count: %u", threshold, messagesInQueue);
+ bAnswer = true;
+ }
+
+ if (messagesInQueue > maxQueueDepth)
+ {
+ maxQueueDepth = messagesInQueue;
+ }
+
+ return bAnswer;
+}
+
+uint32_t Monitor::getMaxQueueDepth()
+{
+ return maxQueueDepth;
+}
+
+/*
+** -------------------------------------------------------------------
+** getFreeHeap
+**
+** Returns the current heap size and remember the minimum heap.
+**
+** -------------------------------------------------------------------
+*/
+uint32_t Monitor::getFreeHeap()
+{
+ // Get the current heap size
+ uint32_t currentHeap = ESP.getFreeHeap();
+
+ // Update stats
+ if (currentHeap < heapStats.minHeap)
+ {
+ heapStats.minHeap = currentHeap;
+ }
+
+ return currentHeap;
+}
+
+/*
+** -------------------------------------------------------------------
+** getHeapStatus
+**
+** Returns a copy of the current heap statistics.
+**
+** Returns:
+** HeapStats - A struct containing heap information.
+**
+** -------------------------------------------------------------------
+*/
+Monitor::HeapStats Monitor::getHeapStats()
+{
+ return heapStats;
+}
+
+/*
+/*
+** -------------------------------------------------------------------
+** getFormattedUptime
+**
+** Returns the system uptime as a formatted string based on the
+** provided format string.
+**
+** Parameters:
+** formatStr - A C-style format string that specifies the output
+** format. Must contain four unsigned long format
+** specifiers (e.g., "%lu") for days, hours, minutes,
+** and seconds.
+**
+** Format Example:
+** "System Uptime: %lu days, %lu hours, %lu minutes, %lu seconds."
+** "Uptime: %luD %luH %luM %luS"
+**
+** Returns:
+** std::string - A human-readable string representation of uptime.
+**
+** Notes:
+** - The caller must ensure `formatStr` is a valid null-terminated
+** format string with appropriate placeholders.
+** - If `formatStr` is null, a default format will be used.
+**
+** -------------------------------------------------------------------
+*/
+std::string Monitor::getFormattedUptime(const char *pszFormatStr)
+{
+
+ // Get the current uptime in milliseconds
+ unsigned long uptimeMillis = millis();
+
+ // Calculate days, hours, minutes, and seconds
+ unsigned long seconds = (uptimeMillis / 1000) % 60;
+ unsigned long minutes = (uptimeMillis / (1000 * 60)) % 60;
+ unsigned long hours = (uptimeMillis / (1000 * 60 * 60)) % 24;
+ unsigned long days = (uptimeMillis / (1000 * 60 * 60 * 24));
+
+ char buffer[100] = {0};
+ snprintf(buffer, sizeof(buffer), pszFormatStr,
+ static_cast(days),
+ static_cast(hours),
+ static_cast(minutes),
+ static_cast(seconds));
+
+ return std::string(buffer);
+
+}
+
+/*
+** ===================================================================
+** logUptime()
+** Logs the system uptime in a human-readable format (days, hours,
+** minutes, and seconds).
+**
+** Parameters:
+** tag - Logging tag for the uptime log entry
+**
+** Notes:
+** - Uses millis() to calculate the system uptime.
+** - Wraps around after approximately 49.7 days due to the size of
+** the unsigned long returned by millis().
+**
+** Example Output:
+** [General] System Uptime: 3 days, 4 hours, 23 minutes, 15 seconds.
+**
+** ===================================================================
+*/
+void Monitor::logUptime(const char* tag)
+{
+ // Log the uptime
+ spLogI(tag, "%s", Monitor::getFormattedUptime("System Uptime: %lu days, %lu hours, %lu minutes, %lu seconds.").c_str());
+
+}
\ No newline at end of file
diff --git a/src/Monitor.h b/src/Monitor.h
new file mode 100644
index 0000000..fed0ca5
--- /dev/null
+++ b/src/Monitor.h
@@ -0,0 +1,147 @@
+/*-------------------------------------------------------------------------------------------------
+**
+** Monitor.h
+**
+** Utility class for performance and resource monitoring. Provides timing
+** instrumentation, heap tracking, and queue depth monitoring using static
+** methods and internal tracking structures.
+**
+** SPDX-FileCopyrightText: 2025 ThingPulse Ltd., https://thingpulse.com
+** SPDX-License-Identifier: MIT
+**
+** ------------------------------------------------------------------------------------------------
+** Change Log:
+** 2025-01-07 - Electric Diversions - Initial creation.
+** ------------------------------------------------------------------------------------------------
+*/
+
+#pragma once
+
+#include