diff --git a/src/BluetoothStatus.h b/src/BluetoothStatus.h new file mode 100644 index 000000000..e29139001 --- /dev/null +++ b/src/BluetoothStatus.h @@ -0,0 +1,105 @@ +#pragma once +#include "Status.h" +#include "assert.h" +#include "configuration.h" +#include "meshUtils.h" +#include + +namespace meshtastic +{ + +// Describes the state of the Bluetooth connection +// Allows display to handle pairing events without each UI needing to explicitly hook the Bluefruit / NimBLE code +class BluetoothStatus : public Status +{ + public: + enum class ConnectionState { + DISCONNECTED, + PAIRING, + CONNECTED, + }; + + private: + CallbackObserver statusObserver = + CallbackObserver(this, &BluetoothStatus::updateStatus); + + ConnectionState state = ConnectionState::DISCONNECTED; + std::string passkey; // Stored as string, because Bluefruit allows passkeys with a leading zero + + public: + BluetoothStatus() { statusType = STATUS_TYPE_BLUETOOTH; } + + // New BluetoothStatus: connected or disconnected + BluetoothStatus(ConnectionState state) + { + assert(state != ConnectionState::PAIRING); // If pairing, use constructor which specifies passkey + statusType = STATUS_TYPE_BLUETOOTH; + this->state = state; + } + + // New BluetoothStatus: pairing, with passkey + BluetoothStatus(std::string passkey) : Status() + { + statusType = STATUS_TYPE_BLUETOOTH; + this->state = ConnectionState::PAIRING; + this->passkey = passkey; + } + + ConnectionState getConnectionState() const { return this->state; } + + std::string getPasskey() const + { + assert(state == ConnectionState::PAIRING); + return this->passkey; + } + + void observe(Observable *source) { statusObserver.observe(source); } + + bool matches(const BluetoothStatus *newStatus) const + { + if (this->state == newStatus->getConnectionState()) { + // Same state: CONNECTED / DISCONNECTED + if (this->state != ConnectionState::PAIRING) + return true; + // Same state: PAIRING, and passkey matches + else if (this->getPasskey() == newStatus->getPasskey()) + return true; + } + + return false; + } + + int updateStatus(const BluetoothStatus *newStatus) + { + // Has the status changed? + if (!matches(newStatus)) { + // Copy the members + state = newStatus->getConnectionState(); + if (state == ConnectionState::PAIRING) + passkey = newStatus->getPasskey(); + + // Tell anyone interested that we have an update + onNewStatus.notifyObservers(this); + + // Debug only: + switch (state) { + case ConnectionState::PAIRING: + LOG_DEBUG("BluetoothStatus PAIRING, key=%s", passkey.c_str()); + break; + case ConnectionState::CONNECTED: + LOG_DEBUG("BluetoothStatus CONNECTED"); + break; + + case ConnectionState::DISCONNECTED: + LOG_DEBUG("BluetoothStatus DISCONNECTED"); + break; + } + } + + return 0; + } +}; + +} // namespace meshtastic + +extern meshtastic::BluetoothStatus *bluetoothStatus; \ No newline at end of file diff --git a/src/ButtonThread.cpp b/src/ButtonThread.cpp index 5175a2680..ec0bc5fc2 100644 --- a/src/ButtonThread.cpp +++ b/src/ButtonThread.cpp @@ -11,6 +11,7 @@ #include "main.h" #include "modules/ExternalNotificationModule.h" #include "power.h" +#include "sleep.h" #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" #endif @@ -99,6 +100,13 @@ ButtonThread::ButtonThread() : OSThread("Button") userButtonTouch.attachLongPressStart(touchPressedLongStart); // Better handling with longpress than click? #endif +#ifdef ARCH_ESP32 + // Register callbacks for before and after lightsleep + // Used to detach and reattach interrupts + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif + attachButtonInterrupts(); #endif } @@ -320,6 +328,26 @@ void ButtonThread::detachButtonInterrupts() #endif } +#ifdef ARCH_ESP32 + +// Detach our class' interrupts before lightsleep +// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press +int ButtonThread::beforeLightSleep(void *unused) +{ + detachButtonInterrupts(); + return 0; // Indicates success +} + +// Reconfigure our interrupts +// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep +int ButtonThread::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + attachButtonInterrupts(); + return 0; // Indicates success +} + +#endif + /** * Watch a GPIO and if we get an IRQ, wake the main thread. * Use to add wake on button press diff --git a/src/ButtonThread.h b/src/ButtonThread.h index a01a1718f..54b833d03 100644 --- a/src/ButtonThread.h +++ b/src/ButtonThread.h @@ -37,6 +37,12 @@ class ButtonThread : public concurrency::OSThread void detachButtonInterrupts(); void storeClickCount(); + // Disconnect and reconnect interrupts for light sleep +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + private: #if defined(BUTTON_PIN) || defined(ARCH_PORTDUINO) || defined(USERPREFS_BUTTON_PIN) static OneButton userButton; // Static - accessed from an interrupt @@ -48,6 +54,14 @@ class ButtonThread : public concurrency::OSThread OneButton userButtonTouch; #endif +#ifdef ARCH_ESP32 + // Get notified when lightsleep begins and ends + CallbackObserver lsObserver = + CallbackObserver(this, &ButtonThread::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &ButtonThread::afterLightSleep); +#endif + // set during IRQ static volatile ButtonEventType btnEvent; diff --git a/src/Status.h b/src/Status.h index 65f3a252f..59d443ab7 100644 --- a/src/Status.h +++ b/src/Status.h @@ -7,6 +7,7 @@ #define STATUS_TYPE_POWER 1 #define STATUS_TYPE_GPS 2 #define STATUS_TYPE_NODE 3 +#define STATUS_TYPE_BLUETOOTH 4 namespace meshtastic { diff --git a/src/graphics/niche/Drivers/Backlight/LatchingBacklight.cpp b/src/graphics/niche/Drivers/Backlight/LatchingBacklight.cpp new file mode 100644 index 000000000..7e4f0b709 --- /dev/null +++ b/src/graphics/niche/Drivers/Backlight/LatchingBacklight.cpp @@ -0,0 +1,110 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./LatchingBacklight.h" + +#include "assert.h" + +#include "sleep.h" + +using namespace NicheGraphics::Drivers; + +// Private constructor +// Called by getInstance +LatchingBacklight::LatchingBacklight() +{ + // Attach the deep sleep callback + deepSleepObserver.observe(¬ifyDeepSleep); +} + +// Get access to (or create) the singleton instance of this class +LatchingBacklight *LatchingBacklight::getInstance() +{ + // Instantiate the class the first time this method is called + static LatchingBacklight *const singletonInstance = new LatchingBacklight; + + return singletonInstance; +} + +// Which pin controls the backlight? +// Is the light active HIGH (default) or active LOW? +void LatchingBacklight::setPin(uint8_t pin, bool activeWhen) +{ + this->pin = pin; + this->logicActive = activeWhen; + + pinMode(pin, OUTPUT); + off(); // Explicit off seem required by T-Echo? +} + +// Called when device is shutting down +// Ensures the backlight is off +int LatchingBacklight::beforeDeepSleep(void *unused) +{ + // We shouldn't need to guard the block like this + // Contingency for: + // - settings corruption: settings.optionalMenuItems.backlight guards backlight code in MenuApplet + // - improper use in the future + if (pin != (uint8_t)-1) { + off(); + pinMode(pin, INPUT); // High impedence - unnecessary? + } else + LOG_WARN("LatchingBacklight instantiated, but pin not set"); + return 0; // Continue with deep sleep +} + +// Turn the backlight on *temporarily* +// This should be used for momentary illumination, such as while a button is held +// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling +void LatchingBacklight::peek() +{ + assert(pin != (uint8_t)-1); + digitalWrite(pin, logicActive); // On + on = true; + latched = false; +} + +// Turn the backlight on, and keep it on +// This should be used when the backlight should remain active, even after user input ends +// e.g. when enabled via the menu +// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling +void LatchingBacklight::latch() +{ + assert(pin != (uint8_t)-1); + + // Blink if moving from peek to latch + // Indicates to user that the transition has taken place + if (on && !latched) { + digitalWrite(pin, !logicActive); // Off + delay(25); + digitalWrite(pin, logicActive); // On + delay(25); + digitalWrite(pin, !logicActive); // Off + delay(25); + } + + digitalWrite(pin, logicActive); // On + on = true; + latched = true; +} + +// Turn the backlight off +// Suitable for ending both peek and latch +void LatchingBacklight::off() +{ + assert(pin != (uint8_t)-1); + digitalWrite(pin, !logicActive); // Off + on = false; + latched = false; +} + +bool LatchingBacklight::isOn() +{ + return on; +} + +bool LatchingBacklight::isLatched() +{ + return latched; +} + +#endif diff --git a/src/graphics/niche/Drivers/Backlight/LatchingBacklight.h b/src/graphics/niche/Drivers/Backlight/LatchingBacklight.h new file mode 100644 index 000000000..0097cae4c --- /dev/null +++ b/src/graphics/niche/Drivers/Backlight/LatchingBacklight.h @@ -0,0 +1,50 @@ +/* + + Singleton class + On-demand control of a display's backlight, connected to a GPIO + Initial use case is control of T-Echo's frontlight, via the capacitive touch button + + - momentary on + - latched on + +*/ + +#pragma once + +#include "configuration.h" + +#include "Observer.h" + +namespace NicheGraphics::Drivers +{ + +class LatchingBacklight +{ + public: + static LatchingBacklight *getInstance(); // Create or get the singleton instance + void setPin(uint8_t pin, bool activeWhen = HIGH); + + int beforeDeepSleep(void *unused); // Callback for auto-shutoff + + void peek(); // Backlight on temporarily, e.g. while button held + void latch(); // Backlight on permanently, e.g. toggled via menu + void off(); // Backlight off. Suitable for both peek and latch + + bool isOn(); // Either peek or latch + bool isLatched(); + + private: + LatchingBacklight(); // Constructor made private: force use of getInstance + + // Get notified when the system is shutting down + CallbackObserver deepSleepObserver = + CallbackObserver(this, &LatchingBacklight::beforeDeepSleep); + + uint8_t pin = (uint8_t)-1; + bool logicActive = HIGH; // Is light active HIGH or active LOW + + bool on = false; // Is light on (either peek or latched) + bool latched = false; // Is light latched on +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.cpp b/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.cpp new file mode 100644 index 000000000..b8715ed1d --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.cpp @@ -0,0 +1 @@ +#include "./DEPG0154BNS800.h" \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.h b/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.h new file mode 100644 index 000000000..47a50c308 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/DEPG0154BNS800.h @@ -0,0 +1,32 @@ +/* + +E-Ink display driver + - DEPG0154BNS800 + - Manufacturer: DKE + - Size: 1.54 inch + - Resolution: 152px x 152px + - Flex connector marking: FPC7525 + +*/ + +#pragma once + +#include "configuration.h" + +#include "./SSD16XX.h" + +namespace NicheGraphics::Drivers +{ +class DEPG0154BNS800 : public SSD16XX +{ + // Display properties + private: + static constexpr uint32_t width = 152; + static constexpr uint32_t height = 152; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL); + + public: + DEPG0154BNS800() : SSD16XX(width, height, supported, 1) {} // Note: left edge of this display is offset by 1 byte +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.cpp b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.cpp new file mode 100644 index 000000000..fd60a527c --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.cpp @@ -0,0 +1,117 @@ +#include "./DEPG0290BNS800.h" + +using namespace NicheGraphics::Drivers; + +// Describes the operation performed when a "fast refresh" is performed +// Source: custom, with DEPG0150BNS810 as a reference +static const uint8_t LUT_FAST[] = { + // 1 2 3 4 + 0x40, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2B (Existing black pixels) + 0x00, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2W (New white pixels) + 0x00, 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2B (New black pixels) + 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2W (Existing white pixels) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // VCOM + + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 1. Tap existing black pixels back into place + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 2. Move new pixels + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 3. New pixels, and also existing black pixels + 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, // 4. All pixels, then cooldown + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00, 0x00, +}; + +// How strongly the pixels are pulled and pushed +void DEPG0290BNS800::configVoltages() +{ + switch (updateType) { + case FAST: + // Listed as "typical" in datasheet + sendCommand(0x04); + sendData(0x41); // VSH1 15V + sendData(0x00); // VSH2 NA + sendData(0x32); // VSL -15V + break; + + case FULL: + default: + // From OTP memory + break; + } +} + +// Load settings about how the pixels are moved from old state to new state during a refresh +// - manually specified, +// - or with stored values from displays OTP memory +void DEPG0290BNS800::configWaveform() +{ + switch (updateType) { + case FAST: + sendCommand(0x3C); // Border waveform: + sendData(0x60); // Actively hold screen border during update + + sendCommand(0x32); // Write LUT register from MCU: + sendData(LUT_FAST, sizeof(LUT_FAST)); // (describes operation for a FAST refresh) + break; + + case FULL: + default: + // From OTP memory + break; + } +} + +// Describes the sequence of events performed by the displays controller IC during a refresh +// Includes "power up", "load settings from memory", "update the pixels", etc +void DEPG0290BNS800::configUpdateSequence() +{ + switch (updateType) { + case FAST: + sendCommand(0x22); // Set "update sequence" + sendData(0xCF); // Differential, use manually loaded waveform + break; + + case FULL: + default: + sendCommand(0x22); // Set "update sequence" + sendData(0xF7); // Non-differential, load waveform from OTP + break; + } +} + +// Once the refresh operation has been started, +// begin periodically polling the display to check for completion, using the normal Meshtastic threading code +// Only used when refresh is "async" +void DEPG0290BNS800::detachFromUpdate() +{ + switch (updateType) { + case FAST: + return beginPolling(50, 450); // At least 450ms for fast refresh + case FULL: + default: + return beginPolling(100, 3000); // At least 3 seconds for full refresh + } +} + +// For this display, we do not need to re-write the new image. +// We're overriding SSD16XX::finalizeUpdate to make this small optimization. +// The display does also work just fine with the generic SSD16XX method, though. +void DEPG0290BNS800::finalizeUpdate() +{ + // Put a copy of the image into the "old memory". + // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place + // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc. + if (updateType != FULL) { + // writeNewImage(); // Not required for this display + writeOldImage(); + sendCommand(0x7F); // Terminate image write without update + wait(); + } +} \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h new file mode 100644 index 000000000..7b754a2d0 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/DEPG0290BNS800.h @@ -0,0 +1,39 @@ +/* + +E-Ink display driver + - DEPG0290BNS800 + - Manufacturer: DKE + - Size: 2.9 inch + - Resolution: 128px x 296px + - Flex connector marking: FPC-7519 rev.b + +*/ + +#pragma once + +#include "configuration.h" + +#include "./SSD16XX.h" + +namespace NicheGraphics::Drivers +{ +class DEPG0290BNS800 : public SSD16XX +{ + // Display properties + private: + static constexpr uint32_t width = 128; + static constexpr uint32_t height = 296; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST); + + public: + DEPG0290BNS800() : SSD16XX(width, height, supported, 1) {} // Note: left edge of this display is offset by 1 byte + + protected: + void configVoltages() override; + void configWaveform() override; + void configUpdateSequence() override; + void detachFromUpdate() override; + void finalizeUpdate() override; // Only overriden for a slight optimization +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/EInk.cpp b/src/graphics/niche/Drivers/EInk/EInk.cpp new file mode 100644 index 000000000..8f95a71b8 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/EInk.cpp @@ -0,0 +1,67 @@ +#include "./EInk.h" + +using namespace NicheGraphics::Drivers; + +// Separate from EInk::begin method, as derived class constructors can probably supply these parameters as constants +EInk::EInk(uint16_t width, uint16_t height, UpdateTypes supported) + : concurrency::OSThread("E-Ink Driver"), width(width), height(height), supportedUpdateTypes(supported) +{ + OSThread::disable(); +} + +// Used by NicheGraphics implementations to check if a display supports a specific refresh operation. +// Whether or the update type is supported is specified in the constructor +bool EInk::supports(UpdateTypes type) +{ + // The EInkUpdateTypes enum assigns each type a unique bit. We are checking if that bit is set. + if (supportedUpdateTypes & type) + return true; + else + return false; +} + +// Begins using the OSThread to detect when a display update is complete +// This allows the refresh operation to run "asynchronously". +// Rather than blocking execution waiting for the update to complete, we are periodically checking the hardware's BUSY pin +// The expectedDuration argument allows us to delay the start of this checking, if we know "roughly" how long an update takes. +// Potentially, a display without hardware BUSY could rely entirely on "expectedDuration", +// provided its isUpdateDone() override always returns true. +void EInk::beginPolling(uint32_t interval, uint32_t expectedDuration) +{ + updateRunning = true; + updateBegunAt = millis(); + pollingInterval = interval; + + // To minimize load, we can choose to delay polling for a few seconds, if we know roughly how long the update will take + // By default, expectedDuration is 0, and we'll start polling immediately + OSThread::setIntervalFromNow(expectedDuration); + OSThread::enabled = true; +} + +// Meshtastic's pseudo-threading layer +// We're using this as a timer, to periodically check if an update is complete +// This is what allows us to update the display asynchronously +int32_t EInk::runOnce() +{ + if (!isUpdateDone()) + return pollingInterval; // Poll again in a few ms + + // If update done: + finalizeUpdate(); // Any post-update code: power down panel hardware, hibernate, etc + updateRunning = false; // Change what we report via EInk::busy() + return disable(); // Stop polling +} + +// Wait for an in progress update to complete before continuing +// Run a normal (async) update first, *then* call await +void EInk::await() +{ + // Stop our concurrency thread + OSThread::disable(); + + // Sit and block until the update is complete + while (updateRunning) { + runOnce(); + yield(); + } +} \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/EInk.h b/src/graphics/niche/Drivers/EInk/EInk.h new file mode 100644 index 000000000..6a841ed71 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/EInk.h @@ -0,0 +1,53 @@ +/* + + Base class for E-Ink display drivers + +*/ + +#pragma once + +#include "configuration.h" + +#include "concurrency/OSThread.h" +#include + +namespace NicheGraphics::Drivers +{ + +class EInk : private concurrency::OSThread +{ + public: + // Different possible operations used to update an E-Ink display + // Some displays will not support all operations + // Each value needs a unique bit. In some cases, we might set more than one bit (e.g. EInk::supportedUpdateType) + enum UpdateTypes : uint8_t { + UNSPECIFIED = 0, + FULL = 1 << 0, + FAST = 1 << 1, + }; + + EInk(uint16_t width, uint16_t height, UpdateTypes supported); + virtual void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) = 0; + virtual void update(uint8_t *imageData, UpdateTypes type) = 0; // Change the display image + void await(); // Wait for an in-progress update to complete before proceeding + bool supports(UpdateTypes type); // Can display perfom a certain update type + bool busy() { return updateRunning; } // Display able to update right now? + + const uint16_t width; // Public so that NicheGraphics implementations can access. Safe because const. + const uint16_t height; + + protected: + void beginPolling(uint32_t interval, uint32_t expectedDuration); // Begin checking repeatedly if update finished + virtual bool isUpdateDone() = 0; // Check once if update finished + virtual void finalizeUpdate() {} // Run any post-update code + + private: + int32_t runOnce() override; // Repeated checking if update finished + + const UpdateTypes supportedUpdateTypes; // Capabilities of a derived display class + bool updateRunning = false; // see EInk::busy() + uint32_t updateBegunAt; // For initial pause before polling for update completion + uint32_t pollingInterval; // How often to check if update complete (ms) +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp b/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp new file mode 100644 index 000000000..b917c4d4d --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEY0154D67.cpp @@ -0,0 +1,58 @@ +#include "./GDEY0154D67.h" + +using namespace NicheGraphics::Drivers; + +// Map the display controller IC's output to the conected panel +void GDEY0154D67::configScanning() +{ + // "Driver output control" + sendCommand(0x01); + sendData(0xC7); + sendData(0x00); + sendData(0x00); + + // To-do: delete this method? + // Values set here might be redundant: C7, 00, 00 seems to be default +} + +// Specify which information is used to control the sequence of voltages applied to move the pixels +// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from +// the controller IC's OTP memory, when the update procedure begins. +void GDEY0154D67::configWaveform() +{ + sendCommand(0x3C); // Border waveform: + sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white) + + sendCommand(0x18); // Temperature sensor: + sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform +} + +void GDEY0154D67::configUpdateSequence() +{ + switch (updateType) { + case FAST: + sendCommand(0x22); // Set "update sequence" + sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh" + break; + + case FULL: + default: + sendCommand(0x22); // Set "update sequence" + sendData(0xF7); // Will load LUT from OTP memory + break; + } +} + +// Once the refresh operation has been started, +// begin periodically polling the display to check for completion, using the normal Meshtastic threading code +// Only used when refresh is "async" +void GDEY0154D67::detachFromUpdate() +{ + switch (updateType) { + case FAST: + return beginPolling(50, 500); // At least 500ms for fast refresh + case FULL: + default: + return beginPolling(100, 2000); // At least 2 seconds for full refresh + } +} \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/GDEY0154D67.h b/src/graphics/niche/Drivers/EInk/GDEY0154D67.h new file mode 100644 index 000000000..7b654bd22 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEY0154D67.h @@ -0,0 +1,38 @@ +/* + +E-Ink display driver + - GDEY0154D67 + - Manufacturer: Goodisplay + - Size: 1.54 inch + - Resolution: 200px x 200px + - Flex connector marking: FPC-B001 + +*/ + +#pragma once + +#include "configuration.h" + +#include "./SSD16XX.h" + +namespace NicheGraphics::Drivers +{ +class GDEY0154D67 : public SSD16XX +{ + // Display properties + private: + static constexpr uint32_t width = 200; + static constexpr uint32_t height = 200; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST); + + public: + GDEY0154D67() : SSD16XX(width, height, supported) {} + + protected: + virtual void configScanning() override; + virtual void configWaveform() override; + virtual void configUpdateSequence() override; + void detachFromUpdate() override; +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.cpp b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.cpp new file mode 100644 index 000000000..1e5d2cb56 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.cpp @@ -0,0 +1,297 @@ +#include "./LCMEN2R13EFC1.h" + +#include + +using namespace NicheGraphics::Drivers; + +// Look up table: fast refresh, common electrode +static const uint8_t LUT_FAST_VCOMDC[] = { + 0x01, 0x06, 0x03, 0x02, 0x01, 0x01, 0x01, // + 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +// Look up table: fast refresh, pixels which remain white +static const uint8_t LUT_FAST_WW[] = { + 0x01, 0x06, 0x03, 0x02, 0x81, 0x01, 0x01, // + 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +// Look up table: fast refresh, pixel which change from black to white +static const uint8_t LUT_FAST_BW[] = { + 0x01, 0x86, 0x83, 0x82, 0x81, 0x01, 0x01, // + 0x01, 0x86, 0x82, 0x01, 0x01, 0x01, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +// Look up table: fash refresh, pixels which change from white to black +static const uint8_t LUT_FAST_WB[] = { + 0x01, 0x46, 0x42, 0x01, 0x01, 0x01, 0x01, // + 0x01, 0x46, 0x42, 0x01, 0x01, 0x01, 0x01, // + 0x01, 0x46, 0x43, 0x02, 0x01, 0x01, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +// Look up table: fash refresh, pixels which remain black +static const uint8_t LUT_FAST_BB[] = { + 0x01, 0x06, 0x03, 0x42, 0x41, 0x01, 0x01, // + 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +LCMEN213EFC1::LCMEN213EFC1() : EInk(width, height, supported) +{ + // Pre-calculate size of the image buffer, for convenience + + // Determine the X dimension of the image buffer, in bytes. + // Along rows, pixels are stored 8 per byte. + // Not all display widths are divisible by 8. Need to make sure bytecount accommodates padding for these. + bufferRowSize = ((width - 1) / 8) + 1; + + // Total size of image buffer, in bytes. + bufferSize = bufferRowSize * height; +} + +void LCMEN213EFC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst) +{ + this->spi = spi; + this->pin_dc = pin_dc; + this->pin_cs = pin_cs; + this->pin_busy = pin_busy; + this->pin_rst = pin_rst; + + pinMode(pin_dc, OUTPUT); + pinMode(pin_cs, OUTPUT); + pinMode(pin_busy, INPUT); + + // Reset is active low, hold high + pinMode(pin_rst, INPUT_PULLUP); + + reset(); +} + +void LCMEN213EFC1::update(uint8_t *imageData, UpdateTypes type) +{ + this->updateType = type; + this->buffer = imageData; + + reset(); + + // Config + if (updateType == FULL) + configFull(); + else + configFast(); + + // Transfer image data + if (updateType == FULL) { + writeNewImage(); + writeOldImage(); + } else { + writeNewImage(); + } + + sendCommand(0x04); // Power on the panel voltage + wait(); + + sendCommand(0x12); // Begin executing the update + + // Let the update run async, on display hardware. Base class will poll completion, then finalize. + // For a blocking update, call await after update + detachFromUpdate(); +} + +void LCMEN213EFC1::wait() +{ + // Busy when LOW + while (digitalRead(pin_busy) == LOW) + yield(); +} + +void LCMEN213EFC1::reset() +{ + pinMode(pin_rst, OUTPUT); + digitalWrite(pin_rst, LOW); + delay(10); + pinMode(pin_rst, INPUT_PULLUP); + wait(); + + sendCommand(0x12); + wait(); +} + +void LCMEN213EFC1::sendCommand(const uint8_t command) +{ + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, LOW); // DC pin low indicates command + digitalWrite(pin_cs, LOW); + spi->transfer(command); + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); +} + +void LCMEN213EFC1::sendData(uint8_t data) +{ + // spi->beginTransaction(spiSettings); + // digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command + // digitalWrite(pin_cs, LOW); + // spi->transfer(data); + // digitalWrite(pin_cs, HIGH); + // digitalWrite(pin_dc, HIGH); + // spi->endTransaction(); + sendData(&data, 1); +} + +void LCMEN213EFC1::sendData(const uint8_t *data, uint32_t size) +{ + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command + digitalWrite(pin_cs, LOW); + + // Platform-specific SPI command + // Mothballing. This display model is only used by Heltec Wireless Paper (ESP32) +#if defined(ARCH_ESP32) + spi->transferBytes(data, NULL, size); // NULL for a "write only" transfer +#elif defined(ARCH_NRF52) + spi->transfer(data, NULL, size); // NULL for a "write only" transfer +#else +#error Not implemented yet? Feel free to add other platforms here. +#endif + + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); +} + +void LCMEN213EFC1::configFull() +{ + sendCommand(0x00); // Panel setting register + sendData(0b11 << 6 // Display resolution + | 1 << 4 // B&W only + | 1 << 3 // Vertical scan direction + | 1 << 2 // Horizontal scan direction + | 1 << 1 // Shutdown: no + | 1 << 0 // Reset: no + ); + + sendCommand(0x50); // VCOM and data interval setting register + sendData(0b10 << 6 // Border driven white + | 0b11 << 4 // Invert image colors: no + | 0b0111 << 0 // Interval between VCOM on and image data (default) + ); +} + +void LCMEN213EFC1::configFast() +{ + sendCommand(0x00); // Panel setting register + sendData(0b11 << 6 // Display resolution + | 1 << 5 // LUT from registers (set below) + | 1 << 4 // B&W only + | 1 << 3 // Vertical scan direction + | 1 << 2 // Horizontal scan direction + | 1 << 1 // Shutdown: no + | 1 << 0 // Reset: no + ); + + sendCommand(0x50); // VCOM and data interval setting register + sendData(0b11 << 6 // Border floating + | 0b01 << 4 // Invert image colors: no + | 0b0111 << 0 // Interval between VCOM on and image data (default) + ); + + // Load the various LUTs + sendCommand(0x20); // VCOM + sendData(LUT_FAST_VCOMDC, sizeof(LUT_FAST_VCOMDC)); + + sendCommand(0x21); // White -> White + sendData(LUT_FAST_WW, sizeof(LUT_FAST_WW)); + + sendCommand(0x22); // Black -> White + sendData(LUT_FAST_BW, sizeof(LUT_FAST_BW)); + + sendCommand(0x23); // White -> Black + sendData(LUT_FAST_WB, sizeof(LUT_FAST_WB)); + + sendCommand(0x24); // Black -> Black + sendData(LUT_FAST_BB, sizeof(LUT_FAST_BB)); +} + +void LCMEN213EFC1::writeNewImage() +{ + sendCommand(0x13); + sendData(buffer, bufferSize); +} + +void LCMEN213EFC1::writeOldImage() +{ + sendCommand(0x10); + sendData(buffer, bufferSize); +} + +void LCMEN213EFC1::detachFromUpdate() +{ + // To save power / cycles, displays can choose to specify an "expected duration" for various refresh types + // If we know a full-refresh takes at least 4 seconds, we can delay polling until 3 seconds have passed + // If not implemented, we'll just poll right from the get-go + switch (updateType) { + case FULL: + EInk::beginPolling(10, 3650); + break; + case FAST: + EInk::beginPolling(10, 720); + break; + default: + assert(false); + } +} + +bool LCMEN213EFC1::isUpdateDone() +{ + // Busy when LOW + if (digitalRead(pin_busy) == LOW) + return false; + else + return true; +} + +void LCMEN213EFC1::finalizeUpdate() +{ + // Power off the panel voltages + sendCommand(0x02); + wait(); + + // Put a copy of the image into the "old memory". + // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place + // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc. + if (updateType != FULL) { + writeOldImage(); + wait(); + } +} \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h new file mode 100644 index 000000000..a042f6b3d --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/LCMEN2R13EFC1.h @@ -0,0 +1,64 @@ +/* + +E-Ink display driver + - LCMEN213EFC1 + - Manufacturer: Wisevast + - Size: 2.13 inch + - Resolution: 122px x 250px + - Flex connector marking: HINK-E0213A162-FPC-A0 (Hidden, printed on back-side) + +Note: this display uses an uncommon controller IC, Fitipower JD79656. +It is implemented as a "one-off", directly inheriting the EInk base class, unlike SSD16XX displays. + +*/ + +#pragma once + +#include "configuration.h" + +#include "./EInk.h" + +namespace NicheGraphics::Drivers +{ + +class LCMEN213EFC1 : public EInk +{ + // Display properties + private: + static constexpr uint32_t width = 122; + static constexpr uint32_t height = 250; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST); + + public: + LCMEN213EFC1(); + void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst); + void update(uint8_t *imageData, UpdateTypes type) override; + + protected: + void wait(); + void reset(); + void sendCommand(const uint8_t command); + void sendData(const uint8_t data); + void sendData(const uint8_t *data, uint32_t size); + void configFull(); // Configure display for FULL refresh + void configFast(); // Configure display for FAST refresh + void writeNewImage(); + void writeOldImage(); + + void detachFromUpdate(); + bool isUpdateDone(); + void finalizeUpdate(); + + protected: + uint8_t bufferOffsetX; // In bytes. Panel x=0 does not always align with controller x=0. Quirky internal wiring? + uint8_t bufferRowSize; // In bytes. Rows store 8 pixels per byte. Rounded up to fit (e.g. 122px would require 16 bytes) + uint32_t bufferSize; // In bytes. Rows * Columns + uint8_t *buffer; + UpdateTypes updateType; + + uint8_t pin_dc, pin_cs, pin_busy, pin_rst; + SPIClass *spi; + SPISettings spiSettings = SPISettings(6000000, MSBFIRST, SPI_MODE0); +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/README.md b/src/graphics/niche/Drivers/EInk/README.md new file mode 100644 index 000000000..ffe21e507 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/README.md @@ -0,0 +1,82 @@ +# NicheGraphics - E-Ink Driver + +A driver for E-Ink SPI displays. Suitable for re-use by various NicheGraphics UIs. + +Your UI should use the class `NicheGraphics::Drivers::EInk` . +When you set up a hardware variant, you will use one of specific display model classes, which extend the EInk class. + +An example setup might look like this: + +```cpp +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // An imaginary UI + YourCustomUI *yourUI = new YourCustomUI(); + + // Setup SPI + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + // Setup Enk driver + Drivers::EInk *driver = new Drivers::DEPG0290BNS800; + driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY); + + // Pass the driver to your UI + YourUI::driver = driver; +} +``` + +## Methods + +### `update(uint8_t *imageData, UpdateTypes type, bool async=true)` + +Update the image on the display + +- _`imageData`_ to draw to the display. +- _`type`_ which type of update to perform. + - `FULL` + - `FAST` + - (Other custom types may be possible) +- _`async`_ whether to wait for update to complete, or continue code execution + +The imageData is a 1-bit image. X-Pixels are 8-per byte, with the MSB being the leftmost pixel. This was not an InkHUD design decision; it is the raw format accepted by the E-Ink display controllers ICs. + +_To-do: add a helper method to `InkHUD::Drivers::EInk` to do this arithmetic for you._ + +```cpp +uint16_t w = driver::width(); +uint16_t h = driver::height(); + +uint8_t image[ (w/8) * h ]; // X pixels are 8-per-byte + +image[0] |= (1 << 7); // Set pixel x=0, y=0 +image[0] |= (1 << 0); // Set pixel x=7, y=0 +image[1] |= (1 << 7); // Set pixel x=8, y=0 + +uint8_t x = 12; +uint8_t y = 2; +uint8_t yBytes = y * (w/8); +uint8_t xBytes = x / 8; +uint8_t xBits = (7-x) % 8; +image[yByte + xByte] |= (1 << xBits); // Set pixel x=12, y=2 +``` + +### `supports(UpdateTypes type)` + +Check if display supports a specific update type. `true` if supported. + +- _`type`_ type to check + +### `busy()` + +Check if display is already performing an `update()`. `true` if already updating. + +### `width()` + +Width of the display, in pixels. Note: most displays are portait. Your UI will need to implement rotation in software. + +### `height()` + +Height of the display, in pixels. Note: most displays are portrait. Your UI will need to implement rotation in software. diff --git a/src/graphics/niche/Drivers/EInk/SSD16XX.cpp b/src/graphics/niche/Drivers/EInk/SSD16XX.cpp new file mode 100644 index 000000000..dcfdf2846 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/SSD16XX.cpp @@ -0,0 +1,225 @@ +#include "./SSD16XX.h" + +using namespace NicheGraphics::Drivers; + +SSD16XX::SSD16XX(uint16_t width, uint16_t height, UpdateTypes supported, uint8_t bufferOffsetX) + : EInk(width, height, supported), bufferOffsetX(bufferOffsetX) +{ + // Pre-calculate size of the image buffer, for convenience + + // Determine the X dimension of the image buffer, in bytes. + // Along rows, pixels are stored 8 per byte. + // Not all display widths are divisible by 8. Need to make sure bytecount accommodates padding for these. + bufferRowSize = ((width - 1) / 8) + 1; + + // Total size of image buffer, in bytes. + bufferSize = bufferRowSize * height; +} + +void SSD16XX::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst) +{ + this->spi = spi; + this->pin_dc = pin_dc; + this->pin_cs = pin_cs; + this->pin_busy = pin_busy; + this->pin_rst = pin_rst; + + pinMode(pin_dc, OUTPUT); + pinMode(pin_cs, OUTPUT); + pinMode(pin_busy, INPUT); + + // If using a reset pin, hold high + // Reset is active low for solmon systech ICs + if (pin_rst != 0xFF) + pinMode(pin_rst, INPUT_PULLUP); + + reset(); +} + +void SSD16XX::wait() +{ + // Busy when HIGH + while (digitalRead(pin_busy) == HIGH) + yield(); +} + +void SSD16XX::reset() +{ + // Check if reset pin is defined + if (pin_rst != 0xFF) { + pinMode(pin_rst, OUTPUT); + digitalWrite(pin_rst, LOW); + delay(50); + pinMode(pin_rst, INPUT_PULLUP); + wait(); + } + + sendCommand(0x12); + wait(); +} + +void SSD16XX::sendCommand(const uint8_t command) +{ + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, LOW); // DC pin low indicates command + digitalWrite(pin_cs, LOW); + spi->transfer(command); + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); +} + +void SSD16XX::sendData(uint8_t data) +{ + // spi->beginTransaction(spiSettings); + // digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command + // digitalWrite(pin_cs, LOW); + // spi->transfer(data); + // digitalWrite(pin_cs, HIGH); + // digitalWrite(pin_dc, HIGH); + // spi->endTransaction(); + sendData(&data, 1); +} + +void SSD16XX::sendData(const uint8_t *data, uint32_t size) +{ + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command + digitalWrite(pin_cs, LOW); + + // Platform-specific SPI command +#if defined(ARCH_ESP32) + spi->transferBytes(data, NULL, size); // NULL for a "write only" transfer +#elif defined(ARCH_NRF52) + spi->transfer(data, NULL, size); // NULL for a "write only" transfer +#else +#error Not implemented yet? Feel free to add other platforms here. +#endif + + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); +} + +void SSD16XX::configFullscreen() +{ + // Placing this code in a separate method because it's probably pretty consistent between displays + // Should make it tidier to override SSD16XX::configure + + // Define the boundaries of the "fullscreen" region, for the controller IC + static const uint16_t sx = bufferOffsetX; // Notice the offset + static const uint16_t sy = 0; + static const uint16_t ex = bufferRowSize + bufferOffsetX - 1; // End is "max index", not "count". Minus 1 handles this + static const uint16_t ey = height; + + // Split into bytes + static const uint8_t sy1 = sy & 0xFF; + static const uint8_t sy2 = (sy >> 8) & 0xFF; + static const uint8_t ey1 = ey & 0xFF; + static const uint8_t ey2 = (ey >> 8) & 0xFF; + + // Data entry mode - Left to Right, Top to Bottom + sendCommand(0x11); + sendData(0x03); + + // Select controller IC memory region to display a fullscreen image + sendCommand(0x44); // Memory X start - end + sendData(sx); + sendData(ex); + sendCommand(0x45); // Memory Y start - end + sendData(sy1); + sendData(sy2); + sendData(ey1); + sendData(ey2); + + // Place the cursor at the start of this memory region, ready to send image data x=0 y=0 + sendCommand(0x4E); // Memory cursor X + sendData(sx); + sendCommand(0x4F); // Memory cursor y + sendData(sy1); + sendData(sy2); +} + +void SSD16XX::update(uint8_t *imageData, UpdateTypes type) +{ + this->updateType = type; + this->buffer = imageData; + + reset(); + + configFullscreen(); + configScanning(); // Virtual, unused by base class + configVoltages(); // Virtual, unused by base class + configWaveform(); // Virtual, unused by base class + wait(); + + if (updateType == FULL) { + writeNewImage(); + writeOldImage(); + } else { + writeNewImage(); + } + + configUpdateSequence(); + sendCommand(0x20); // Begin executing the update + + // Let the update run async, on display hardware. Base class will poll completion, then finalize. + // For a blocking update, call await after update + detachFromUpdate(); +} + +// Send SPI commands for controller IC to begin executing the refresh operation +void SSD16XX::configUpdateSequence() +{ + switch (updateType) { + default: + sendCommand(0x22); // Set "update sequence" + sendData(0xF7); // Non-differential, load waveform from OTP + break; + } +} + +void SSD16XX::writeNewImage() +{ + sendCommand(0x24); + sendData(buffer, bufferSize); +} + +void SSD16XX::writeOldImage() +{ + sendCommand(0x26); + sendData(buffer, bufferSize); +} + +void SSD16XX::detachFromUpdate() +{ + // To save power / cycles, displays can choose to specify an "expected duration" for various refresh types + // If we know a full-refresh takes at least 4 seconds, we can delay polling until 3 seconds have passed + // If not implemented, we'll just poll right from the get-go + switch (updateType) { + default: + EInk::beginPolling(100, 0); + } +} + +bool SSD16XX::isUpdateDone() +{ + // Busy when HIGH + if (digitalRead(pin_busy) == HIGH) + return false; + else + return true; +} + +void SSD16XX::finalizeUpdate() +{ + // Put a copy of the image into the "old memory". + // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place + // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc. + if (updateType != FULL) { + writeNewImage(); // Only required by some controller variants. Todo: Override just for GDEY0154D678? + writeOldImage(); + sendCommand(0x7F); // Terminate image write without update + wait(); + } +} \ No newline at end of file diff --git a/src/graphics/niche/Drivers/EInk/SSD16XX.h b/src/graphics/niche/Drivers/EInk/SSD16XX.h new file mode 100644 index 000000000..53e4f9e70 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/SSD16XX.h @@ -0,0 +1,58 @@ +/* + +E-Ink base class for displays based on SSD16XX + +Most (but not all) SPI E-Ink displays use this family of controller IC. +Implementing new SSD16XX displays should be fairly painless. +See DEPG0154BNS800 and DEPG0290BNS800 for examples. + +*/ + +#pragma once + +#include "configuration.h" + +#include "./EInk.h" + +namespace NicheGraphics::Drivers +{ + +class SSD16XX : public EInk +{ + public: + SSD16XX(uint16_t width, uint16_t height, UpdateTypes supported, uint8_t bufferOffsetX = 0); + virtual void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1); + virtual void update(uint8_t *imageData, UpdateTypes type) override; + + protected: + virtual void wait(); + virtual void reset(); + virtual void sendCommand(const uint8_t command); + virtual void sendData(const uint8_t data); + virtual void sendData(const uint8_t *data, uint32_t size); + virtual void configFullscreen(); // Select memory region on controller IC + virtual void configScanning() {} // Optional. First & last gates, scan direction, etc + virtual void configVoltages() {} // Optional. Manual panel voltages, soft-start, etc + virtual void configWaveform() {} // Optional. LUT, panel border, temperature sensor, etc + virtual void configUpdateSequence(); // Tell controller IC which operations to run + + virtual void writeNewImage(); + virtual void writeOldImage(); + + virtual void detachFromUpdate(); + virtual bool isUpdateDone() override; + virtual void finalizeUpdate() override; + + protected: + uint8_t bufferOffsetX; // In bytes. Panel x=0 does not always align with controller x=0. Quirky internal wiring? + uint8_t bufferRowSize; // In bytes. Rows store 8 pixels per byte. Rounded up to fit (e.g. 122px would require 16 bytes) + uint32_t bufferSize; // In bytes. Rows * Columns + uint8_t *buffer; + UpdateTypes updateType; + + uint8_t pin_dc, pin_cs, pin_busy, pin_rst; + SPIClass *spi; + SPISettings spiSettings = SPISettings(4000000, MSBFIRST, SPI_MODE0); +}; + +} // namespace NicheGraphics::Drivers \ No newline at end of file diff --git a/src/graphics/niche/Drivers/README.md b/src/graphics/niche/Drivers/README.md new file mode 100644 index 000000000..566558658 --- /dev/null +++ b/src/graphics/niche/Drivers/README.md @@ -0,0 +1,3 @@ +# NicheGraphics - Drivers + +Common drivers which can be used by various NicheGrapihcs UIs diff --git a/src/graphics/niche/FlashData.h b/src/graphics/niche/FlashData.h new file mode 100644 index 000000000..4a436d387 --- /dev/null +++ b/src/graphics/niche/FlashData.h @@ -0,0 +1,140 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +/* + +Re-usable NicheGraphics tool + +Save settings / data to flash, without use of the Meshtastic Protobufs +Avoid bloating everyone's protobuf code for our one-off UI implementations + +*/ + +#pragma once + +#include "configuration.h" + +#include "SafeFile.h" + +namespace NicheGraphics +{ + +template class FlashData +{ + private: + static std::string getFilename(const char *label) + { + std::string filename; + filename += "/NicheGraphics"; + filename += "/"; + filename += label; + filename += ".data"; + + return filename; + } + + static uint32_t getHash(T *data) + { + uint32_t hash = 0; + + // Sum all bytes of the image buffer together + for (uint32_t i = 0; i < sizeof(T); i++) + hash ^= ((uint8_t *)data)[i] + 1; + + return hash; + } + + public: + static bool load(T *data, const char *label) + { + // Set false if we run into issues + bool okay = true; + + // Get a filename based on the label + std::string filename = getFilename(label); + +#ifdef FSCom + + // Check that the file *does* actually exist + if (!FSCom.exists(filename.c_str())) { + LOG_WARN("'%s' not found. Using default values", filename.c_str()); + okay = false; + return okay; + } + + // Open the file + auto f = FSCom.open(filename.c_str(), FILE_O_READ); + + // If opened, start reading + if (f) { + LOG_INFO("Loading NicheGraphics data '%s'", filename.c_str()); + + // Create an object which will received data from flash + // We read here first, so we can verify the checksum, without committing to overwriting the *data object + // Allows us to retain any defaults that might be set after we declared *data, but before loading settings, + // in case the flash values are corrupt + T flashData; + + // Read the actual data + f.readBytes((char *)&flashData, sizeof(T)); + + // Read the hash + uint32_t savedHash = 0; + f.readBytes((char *)&savedHash, sizeof(savedHash)); + + // Calculate hash of the loaded data, then compare with the saved hash + // If hash looks good, copy the values to the main data object + uint32_t calculatedHash = getHash(&flashData); + if (savedHash != calculatedHash) { + LOG_WARN("'%s' is corrupt (hash mismatch). Using default values", filename.c_str()); + okay = false; + } else + *data = flashData; + + f.close(); + } else { + LOG_ERROR("Could not open / read %s", filename.c_str()); + okay = false; + } +#else + LOG_ERROR("Filesystem not implemented"); + state = LoadFileState::NO_FILESYSTEM; + okay = false; +#endif + return okay; + } + + // Save module's custom data (settings?) to flash. Does use protobufs + static void save(T *data, const char *label) + { + // Get a filename based on the label + std::string filename = getFilename(label); + +#ifdef FSCom + FSCom.mkdir("/NicheGraphics"); + + auto f = SafeFile(filename.c_str(), true); // "true": full atomic. Write new data to temp file, then rename. + + LOG_INFO("Saving %s", filename.c_str()); + + // Calculate a hash of the data + uint32_t hash = getHash(data); + + f.write((uint8_t *)data, sizeof(T)); // Write the actualy data + f.write((uint8_t *)&hash, sizeof(hash)); // Append the hash + + // f.flush(); + + bool writeSucceeded = f.close(); + + if (!writeSucceeded) { + LOG_ERROR("Can't write data!"); + } +#else + LOG_ERROR("ERROR: Filesystem not implemented\n"); +#endif + } +}; + +} // namespace NicheGraphics + +#endif \ No newline at end of file diff --git a/src/graphics/niche/Fonts/FreeSans6pt7b.h b/src/graphics/niche/Fonts/FreeSans6pt7b.h new file mode 100644 index 000000000..c5bcc32c4 --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans6pt7b.h @@ -0,0 +1,129 @@ +#pragma once + +const uint8_t FreeSans6pt7bBitmaps[] PROGMEM = { + 0xAA, 0xA8, 0xC0, 0xF6, 0xA0, 0x24, 0x51, 0xF9, 0x42, 0x9F, 0x92, 0x28, 0x10, 0xE5, 0x55, 0x50, 0xE1, 0x65, 0x55, 0xE1, 0x00, + 0x71, 0x24, 0x89, 0x22, 0x50, 0x74, 0x02, 0x70, 0xA4, 0x49, 0x11, 0xC0, 0x70, 0x91, 0x23, 0x86, 0x12, 0xA2, 0x4E, 0xF4, 0xE0, + 0x5A, 0xAA, 0x94, 0x89, 0x12, 0x49, 0x29, 0x00, 0x27, 0x50, 0x21, 0x3E, 0x42, 0x00, 0xE0, 0xC0, 0x80, 0x24, 0xA4, 0xA4, 0x80, + 0x74, 0xE3, 0x18, 0xC6, 0x33, 0x70, 0x27, 0x92, 0x49, 0x20, 0x79, 0x10, 0x41, 0x08, 0xC6, 0x10, 0xFC, 0x79, 0x30, 0x43, 0x18, + 0x10, 0x71, 0x78, 0x08, 0x61, 0x8A, 0x49, 0x2F, 0xC2, 0x08, 0x7D, 0x04, 0x1E, 0x44, 0x10, 0x51, 0x78, 0x74, 0x61, 0xE8, 0xC6, + 0x31, 0x70, 0xF8, 0x44, 0x22, 0x11, 0x08, 0x40, 0x39, 0x34, 0x53, 0x39, 0x1C, 0x51, 0x38, 0x39, 0x3C, 0x71, 0x4C, 0xF0, 0x53, + 0x78, 0x82, 0x87, 0x01, 0xF1, 0x83, 0x04, 0xF8, 0x3E, 0x07, 0x06, 0x36, 0x40, 0x74, 0x42, 0x11, 0x10, 0x80, 0x20, 0x0F, 0x86, + 0x19, 0x9A, 0xA4, 0xD9, 0x13, 0x22, 0x56, 0xDA, 0x6E, 0x60, 0x06, 0x00, 0x3C, 0x00, 0x18, 0x18, 0x3C, 0x24, 0x24, 0x7E, 0x42, + 0x42, 0xC3, 0xFA, 0x18, 0x61, 0xFA, 0x18, 0x61, 0xFC, 0x3E, 0x63, 0x40, 0x40, 0xC0, 0x40, 0x41, 0x63, 0x3E, 0xF9, 0x0A, 0x1C, + 0x18, 0x30, 0x61, 0xC2, 0xF8, 0xFE, 0x08, 0x20, 0xFE, 0x08, 0x20, 0xFC, 0xFE, 0x08, 0x20, 0xFA, 0x08, 0x20, 0x80, 0x1E, 0x61, + 0x40, 0x40, 0xC7, 0x41, 0x41, 0x63, 0x1D, 0x83, 0x06, 0x0C, 0x1F, 0xF0, 0x60, 0xC1, 0x82, 0xFF, 0x80, 0x08, 0x42, 0x10, 0x87, + 0x29, 0x70, 0x85, 0x12, 0x45, 0x0D, 0x13, 0x22, 0x42, 0x86, 0x84, 0x21, 0x08, 0x42, 0x10, 0xF8, 0xC3, 0xC3, 0xC3, 0xA5, 0xA5, + 0xA5, 0x99, 0x99, 0x99, 0x83, 0x86, 0x8D, 0x19, 0x33, 0x62, 0xC3, 0x86, 0x1E, 0x31, 0x90, 0x68, 0x1C, 0x0A, 0x05, 0x06, 0xC6, + 0x1E, 0x00, 0xFA, 0x18, 0x61, 0xFA, 0x08, 0x20, 0x80, 0x1E, 0x31, 0x90, 0x68, 0x1C, 0x0A, 0x05, 0x06, 0xC6, 0x1F, 0x00, 0x00, + 0xFD, 0x0E, 0x1C, 0x2F, 0x90, 0xA1, 0x42, 0x86, 0x7A, 0x18, 0x30, 0x78, 0x38, 0x61, 0x78, 0xFE, 0x20, 0x40, 0x81, 0x02, 0x04, + 0x08, 0x10, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xE2, 0x78, 0xC2, 0x42, 0x42, 0x64, 0x24, 0x24, 0x38, 0x18, 0x18, 0xC4, 0x28, + 0xCD, 0x29, 0x25, 0x24, 0xA4, 0x52, 0x8C, 0x61, 0x8C, 0x31, 0x80, 0x42, 0x66, 0x24, 0x18, 0x18, 0x18, 0x24, 0x46, 0x42, 0xC3, + 0x42, 0x24, 0x34, 0x18, 0x08, 0x08, 0x08, 0x08, 0x7E, 0x0C, 0x30, 0x41, 0x06, 0x18, 0x20, 0xFE, 0xEA, 0xAA, 0xAB, 0x92, 0x24, + 0x89, 0x20, 0xE9, 0x24, 0x92, 0x49, 0x70, 0x46, 0xA9, 0x10, 0xFE, 0x40, 0x79, 0x20, 0x4F, 0xC6, 0x37, 0x40, 0x84, 0x3D, 0x18, + 0xC6, 0x31, 0xF0, 0x39, 0x3C, 0x20, 0xC1, 0x33, 0x80, 0x04, 0x13, 0xD3, 0xC6, 0x1C, 0x53, 0x3C, 0x39, 0x38, 0x7F, 0x81, 0x13, + 0x80, 0x6B, 0xA4, 0x92, 0x40, 0x35, 0x3C, 0x61, 0xC5, 0x33, 0x41, 0x4D, 0xE0, 0x84, 0x3D, 0x38, 0xC6, 0x31, 0x88, 0xBF, 0x80, + 0x45, 0x55, 0x57, 0x84, 0x25, 0x4E, 0x52, 0xD2, 0x88, 0xFF, 0x80, 0xF7, 0x99, 0x91, 0x91, 0x91, 0x91, 0x91, 0xF4, 0x63, 0x18, + 0xC6, 0x20, 0x39, 0x3C, 0x61, 0xC5, 0x33, 0x80, 0xF4, 0x63, 0x18, 0xC7, 0xD0, 0x80, 0x3D, 0x3C, 0x61, 0xC5, 0x37, 0x41, 0x04, + 0xF2, 0x49, 0x20, 0x79, 0x24, 0x1C, 0x0B, 0x27, 0x80, 0x5D, 0x24, 0x93, 0x8C, 0x63, 0x18, 0xCF, 0xA0, 0x85, 0x24, 0x92, 0x30, + 0xC3, 0x00, 0x89, 0x2C, 0x96, 0x4A, 0xA5, 0x61, 0x30, 0x98, 0x49, 0x23, 0x08, 0x31, 0x2C, 0x80, 0x89, 0x24, 0x94, 0x50, 0xC2, + 0x08, 0x21, 0x00, 0x78, 0x44, 0x46, 0x23, 0xE0, 0x6A, 0xAA, 0xA9, 0xFF, 0xE0, 0x95, 0x55, 0x56, 0x66, 0x60}; + +const GFXglyph FreeSans6pt7bGlyphs[] PROGMEM = {{0, 0, 0, 3, 0, 1}, // 0x20 ' ' + {0, 2, 9, 4, 1, -8}, // 0x21 '!' + {3, 4, 3, 4, 0, -8}, // 0x22 '"' + {5, 7, 8, 7, 0, -7}, // 0x23 '#' + {12, 6, 11, 7, 0, -9}, // 0x24 '$' + {21, 10, 9, 11, 0, -8}, // 0x25 '%' + {33, 7, 9, 8, 1, -8}, // 0x26 '&' + {41, 1, 3, 2, 1, -8}, // 0x27 ''' + {42, 2, 11, 4, 1, -8}, // 0x28 '(' + {45, 3, 11, 4, 0, -8}, // 0x29 ')' + {50, 4, 3, 5, 0, -8}, // 0x2A '*' + {52, 5, 5, 7, 1, -4}, // 0x2B '+' + {56, 1, 3, 3, 1, 0}, // 0x2C ',' + {57, 2, 1, 4, 1, -3}, // 0x2D '-' + {58, 1, 1, 3, 1, 0}, // 0x2E '.' + {59, 3, 9, 3, 0, -8}, // 0x2F '/' + {63, 5, 9, 7, 1, -8}, // 0x30 '0' + {69, 3, 9, 7, 1, -8}, // 0x31 '1' + {73, 6, 9, 7, 0, -8}, // 0x32 '2' + {80, 6, 9, 7, 0, -8}, // 0x33 '3' + {87, 6, 9, 7, 0, -8}, // 0x34 '4' + {94, 6, 9, 7, 0, -8}, // 0x35 '5' + {101, 5, 9, 7, 1, -8}, // 0x36 '6' + {107, 5, 9, 7, 1, -8}, // 0x37 '7' + {113, 6, 9, 7, 0, -8}, // 0x38 '8' + {120, 6, 9, 7, 0, -8}, // 0x39 '9' + {127, 1, 7, 3, 1, -6}, // 0x3A ':' + {128, 1, 8, 3, 1, -5}, // 0x3B ';' + {129, 5, 6, 7, 1, -5}, // 0x3C '<' + {133, 5, 3, 7, 1, -3}, // 0x3D '=' + {135, 5, 6, 7, 1, -5}, // 0x3E '>' + {139, 5, 9, 7, 1, -8}, // 0x3F '?' + {145, 11, 11, 12, 0, -8}, // 0x40 '@' + {161, 8, 9, 8, 0, -8}, // 0x41 'A' + {170, 6, 9, 8, 1, -8}, // 0x42 'B' + {177, 8, 9, 9, 0, -8}, // 0x43 'C' + {186, 7, 9, 8, 1, -8}, // 0x44 'D' + {194, 6, 9, 8, 1, -8}, // 0x45 'E' + {201, 6, 9, 7, 1, -8}, // 0x46 'F' + {208, 8, 9, 9, 0, -8}, // 0x47 'G' + {217, 7, 9, 9, 1, -8}, // 0x48 'H' + {225, 1, 9, 3, 1, -8}, // 0x49 'I' + {227, 5, 9, 6, 0, -8}, // 0x4A 'J' + {233, 7, 9, 8, 1, -8}, // 0x4B 'K' + {241, 5, 9, 7, 1, -8}, // 0x4C 'L' + {247, 8, 9, 10, 1, -8}, // 0x4D 'M' + {256, 7, 9, 9, 1, -8}, // 0x4E 'N' + {264, 9, 9, 9, 0, -8}, // 0x4F 'O' + {275, 6, 9, 8, 1, -8}, // 0x50 'P' + {282, 9, 10, 9, 0, -8}, // 0x51 'Q' + {294, 7, 9, 9, 1, -8}, // 0x52 'R' + {302, 6, 9, 8, 1, -8}, // 0x53 'S' + {309, 7, 9, 8, 0, -8}, // 0x54 'T' + {317, 7, 9, 9, 1, -8}, // 0x55 'U' + {325, 8, 9, 8, 0, -8}, // 0x56 'V' + {334, 11, 9, 11, 0, -8}, // 0x57 'W' + {347, 8, 9, 8, 0, -8}, // 0x58 'X' + {356, 8, 9, 8, 0, -8}, // 0x59 'Y' + {365, 7, 9, 7, 0, -8}, // 0x5A 'Z' + {373, 2, 12, 3, 1, -8}, // 0x5B '[' + {376, 3, 9, 3, 0, -8}, // 0x5C '\' + {380, 3, 12, 3, 0, -8}, // 0x5D ']' + {385, 4, 5, 6, 1, -8}, // 0x5E '^' + {388, 7, 1, 7, 0, 2}, // 0x5F '_' + {389, 3, 1, 3, 0, -8}, // 0x60 '`' + {390, 6, 7, 7, 0, -6}, // 0x61 'a' + {396, 5, 9, 7, 1, -8}, // 0x62 'b' + {402, 6, 7, 6, 0, -6}, // 0x63 'c' + {408, 6, 9, 7, 0, -8}, // 0x64 'd' + {415, 6, 7, 6, 0, -6}, // 0x65 'e' + {421, 3, 9, 3, 0, -8}, // 0x66 'f' + {425, 6, 10, 7, 0, -6}, // 0x67 'g' + {433, 5, 9, 6, 1, -8}, // 0x68 'h' + {439, 1, 9, 3, 1, -8}, // 0x69 'i' + {441, 2, 12, 3, 0, -8}, // 0x6A 'j' + {444, 5, 9, 6, 1, -8}, // 0x6B 'k' + {450, 1, 9, 3, 1, -8}, // 0x6C 'l' + {452, 8, 7, 10, 1, -6}, // 0x6D 'm' + {459, 5, 7, 6, 1, -6}, // 0x6E 'n' + {464, 6, 7, 6, 0, -6}, // 0x6F 'o' + {470, 5, 9, 7, 1, -6}, // 0x70 'p' + {476, 6, 9, 7, 0, -6}, // 0x71 'q' + {483, 3, 7, 4, 1, -6}, // 0x72 'r' + {486, 6, 7, 6, 0, -6}, // 0x73 's' + {492, 3, 8, 3, 0, -7}, // 0x74 't' + {495, 5, 7, 6, 1, -6}, // 0x75 'u' + {500, 6, 7, 6, 0, -6}, // 0x76 'v' + {506, 9, 7, 9, 0, -6}, // 0x77 'w' + {514, 6, 7, 6, 0, -6}, // 0x78 'x' + {520, 6, 10, 6, 0, -6}, // 0x79 'y' + {528, 5, 7, 6, 0, -6}, // 0x7A 'z' + {533, 2, 12, 4, 1, -8}, // 0x7B '{' + {536, 1, 11, 3, 1, -8}, // 0x7C '|' + {538, 2, 12, 4, 1, -8}, // 0x7D '}' + {541, 6, 2, 6, 0, -4}}; // 0x7E '~' + +const GFXfont FreeSans6pt7b PROGMEM = {(uint8_t *)FreeSans6pt7bBitmaps, (GFXglyph *)FreeSans6pt7bGlyphs, 0x20, 0x7E, 14}; + +// Approx. 1215 bytes diff --git a/src/graphics/niche/Fonts/FreeSans6pt8bCyrillic.h b/src/graphics/niche/Fonts/FreeSans6pt8bCyrillic.h new file mode 100644 index 000000000..49f03d4e1 --- /dev/null +++ b/src/graphics/niche/Fonts/FreeSans6pt8bCyrillic.h @@ -0,0 +1,302 @@ +/* + +Uses Windows-1251 encoding to map translingual Cyrillic characters to range between (uint8_t)127 and (uint8_t)255 +https://en.wikipedia.org/wiki/Windows-1251 + +Cyrillic characters present to the firmware as UTF8. +A Niche Graphics implementation needs to identify these, and subsitute the appropriate Windows-1251 char value. + +*/ + +#pragma once + +const uint8_t FreeSans6pt8bCyrillicBitmaps[] PROGMEM = { + 0xFF, 0xA0, 0xC0, 0xFF, 0xA0, 0xC0, 0xB6, 0x80, 0x24, 0x51, 0xF9, 0x42, 0x9F, 0x92, 0x28, 0x31, 0x75, 0x54, 0x78, 0x79, 0x75, + 0x7C, 0x41, 0x00, 0x01, 0x1C, 0x49, 0x22, 0x50, 0x74, 0x02, 0x60, 0xA4, 0x49, 0x11, 0xC0, 0x21, 0x44, 0x94, 0x62, 0x59, 0xE2, + 0xF4, 0xE0, 0x6A, 0xAA, 0x90, 0x48, 0x92, 0x49, 0x4A, 0x00, 0x5D, 0x40, 0x21, 0x09, 0xF2, 0x10, 0xE0, 0xC0, 0x80, 0x25, 0x25, + 0x24, 0x26, 0xA3, 0x18, 0xC6, 0x31, 0xF0, 0x27, 0x92, 0x49, 0x20, 0x11, 0xB4, 0x41, 0x0C, 0xC6, 0x10, 0xFC, 0x26, 0xA2, 0x13, + 0x04, 0x31, 0xF0, 0x08, 0x61, 0x8A, 0x49, 0x2F, 0xC2, 0x08, 0xFF, 0xE1, 0x4D, 0x84, 0x31, 0xF0, 0x26, 0xE3, 0x0F, 0x46, 0x31, + 0xF0, 0xFF, 0xC4, 0x22, 0x11, 0x08, 0x40, 0x11, 0xA4, 0x51, 0x39, 0x1C, 0x51, 0x78, 0x11, 0xA4, 0x71, 0x45, 0xF0, 0x51, 0x78, + 0xC0, 0x30, 0xC0, 0x36, 0x1F, 0x20, 0xE0, 0x80, 0xF8, 0x3E, 0xC1, 0xC2, 0xE8, 0x00, 0x74, 0x62, 0x11, 0x10, 0x80, 0x20, 0x0F, + 0x06, 0x18, 0x81, 0xA7, 0xD4, 0x93, 0x22, 0x64, 0x4A, 0x7E, 0x60, 0x06, 0x00, 0x3C, 0x00, 0x18, 0x18, 0x1C, 0x24, 0x24, 0x7E, + 0x42, 0x42, 0xC3, 0xFA, 0x38, 0x61, 0xFA, 0x18, 0x61, 0xFC, 0x38, 0x8A, 0x0C, 0x08, 0x10, 0x20, 0xE3, 0x7C, 0xF9, 0x1A, 0x1C, + 0x18, 0x30, 0x60, 0xC2, 0xF8, 0xFE, 0x08, 0x20, 0xFE, 0x08, 0x20, 0xFC, 0xFE, 0x08, 0x20, 0xFA, 0x08, 0x20, 0x80, 0x3C, 0x46, + 0x82, 0x80, 0x8F, 0x81, 0x83, 0xC3, 0x7D, 0x83, 0x06, 0x0C, 0x1F, 0xF0, 0x60, 0xC1, 0x82, 0xFF, 0x80, 0x08, 0x42, 0x10, 0x86, + 0x31, 0x78, 0x87, 0x1A, 0x65, 0x8F, 0x1A, 0x22, 0x42, 0x86, 0x84, 0x21, 0x08, 0x42, 0x10, 0xF8, 0xC3, 0xC3, 0xC3, 0xA5, 0xA5, + 0xA5, 0x99, 0x99, 0x99, 0x83, 0x87, 0x8D, 0x19, 0x32, 0x62, 0xC3, 0x86, 0x1E, 0x11, 0x90, 0x48, 0x1C, 0x0A, 0x05, 0x06, 0xC2, + 0x3E, 0x00, 0xFA, 0x18, 0x61, 0xFE, 0x08, 0x20, 0x80, 0x1E, 0x11, 0x90, 0x48, 0x1C, 0x0A, 0x05, 0x06, 0xC6, 0x3F, 0x00, 0xFD, + 0x0E, 0x0C, 0x1F, 0xD0, 0xA0, 0xC1, 0x82, 0x7A, 0x18, 0x70, 0x78, 0x38, 0x61, 0x7C, 0xFE, 0x20, 0x40, 0x81, 0x02, 0x04, 0x08, + 0x10, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC3, 0x7C, 0xC3, 0x42, 0x42, 0x26, 0x24, 0x24, 0x14, 0x18, 0x18, 0xC4, 0x28, 0xC5, + 0x39, 0xA5, 0x24, 0xA4, 0x52, 0x8C, 0x71, 0x8C, 0x30, 0x80, 0x87, 0x34, 0x8C, 0x30, 0xC4, 0xB3, 0x84, 0xC3, 0x42, 0x26, 0x24, + 0x18, 0x18, 0x08, 0x08, 0x08, 0x7E, 0x0C, 0x10, 0x41, 0x06, 0x08, 0x20, 0xFE, 0xEA, 0xAA, 0xAB, 0x92, 0x24, 0x89, 0x20, 0xED, + 0xB6, 0xDB, 0x6D, 0xF0, 0x46, 0xAA, 0x90, 0xFC, 0x90, 0xFC, 0x4F, 0x98, 0xFC, 0x84, 0x21, 0xF8, 0xC6, 0x31, 0xF0, 0x79, 0x18, + 0x20, 0x45, 0xE0, 0x04, 0x10, 0x5F, 0xC6, 0x18, 0x51, 0x7C, 0xFC, 0x7F, 0x08, 0xF8, 0x29, 0x74, 0x92, 0x40, 0x7D, 0x18, 0x61, + 0x45, 0xF0, 0x52, 0x30, 0x84, 0x21, 0xF8, 0xC6, 0x31, 0x88, 0xDF, 0x80, 0x51, 0x55, 0x56, 0x84, 0x21, 0x2A, 0x72, 0x92, 0x98, + 0xFF, 0x80, 0xFF, 0x99, 0x99, 0x99, 0x99, 0x99, 0xFC, 0x63, 0x18, 0xC4, 0x79, 0x18, 0x71, 0x45, 0xE0, 0xFC, 0x63, 0x18, 0xFA, + 0x10, 0x80, 0x7D, 0x18, 0x61, 0x45, 0xF0, 0x41, 0x04, 0xF2, 0x49, 0x00, 0x79, 0x07, 0x02, 0xCD, 0xE0, 0x4B, 0xA4, 0x93, 0x8C, + 0x63, 0x18, 0xFC, 0xCD, 0x24, 0x94, 0x30, 0xC0, 0x99, 0x59, 0x55, 0x56, 0x66, 0x26, 0x96, 0x66, 0x99, 0xCA, 0x52, 0x63, 0x18, + 0x84, 0x40, 0x78, 0xC4, 0x44, 0x7C, 0x6A, 0xAA, 0xA9, 0xFF, 0xF0, 0xC9, 0x24, 0x4A, 0x49, 0x40, 0xE8, 0xC0, 0xFE, 0x18, 0x61, + 0x86, 0x18, 0x61, 0xFC, 0xFC, 0x08, 0x04, 0x02, 0x01, 0xF0, 0x8C, 0x46, 0x23, 0x11, 0x80, 0xC0, 0xC0, 0x10, 0x8F, 0xE0, 0x82, + 0x08, 0x20, 0x82, 0x08, 0x00, 0x64, 0x0F, 0x88, 0x88, 0x80, 0x3D, 0x0C, 0x2E, 0xF9, 0x04, 0x0F, 0x7C, 0x08, 0x81, 0x10, 0x22, + 0x04, 0x7C, 0x88, 0x51, 0x0A, 0x21, 0x87, 0xC0, 0x84, 0x10, 0x82, 0x10, 0x42, 0x0F, 0xFD, 0x08, 0xA1, 0x0C, 0x23, 0x87, 0xC0, + 0x10, 0x88, 0xE6, 0xB3, 0x8C, 0x28, 0x92, 0x28, 0xC0, 0xFC, 0x08, 0x04, 0x02, 0x01, 0xF0, 0x8C, 0x46, 0x23, 0x11, 0x80, 0x83, + 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0xFE, 0x20, 0x40, 0x43, 0xC4, 0x1F, 0x45, 0x14, 0x51, 0x44, 0x11, 0x80, 0x78, 0x24, 0x13, + 0xC9, 0x14, 0x8E, 0x7C, 0x88, 0x44, 0x3F, 0xD1, 0x38, 0x8C, 0x78, 0x60, 0x9A, 0xCC, 0xA9, 0x43, 0xC4, 0x1F, 0x45, 0x14, 0x51, + 0x44, 0x8C, 0x63, 0x18, 0xFC, 0x80, 0x24, 0x33, 0x0A, 0x36, 0x45, 0x8E, 0x0C, 0x10, 0x60, 0x80, 0x70, 0x22, 0x95, 0xA8, 0xC4, + 0x23, 0x10, 0x08, 0x42, 0x10, 0x86, 0x31, 0x78, 0x07, 0xF8, 0x20, 0x82, 0x08, 0x20, 0x82, 0x00, 0x28, 0x0F, 0xE0, 0x82, 0x0F, + 0xE0, 0x82, 0x0F, 0xC0, 0x38, 0x8A, 0x0C, 0x0F, 0x90, 0x20, 0xE3, 0x7C, 0x51, 0x55, 0x56, 0xA1, 0x24, 0x92, 0x49, 0x00, 0xFF, + 0x80, 0xDF, 0x80, 0x27, 0xC9, 0x24, 0x8A, 0x28, 0xA2, 0x8B, 0xF8, 0x20, 0x80, 0x28, 0xA0, 0x1E, 0x47, 0xFC, 0x11, 0x78, 0x88, + 0x44, 0x32, 0x59, 0xDA, 0xCD, 0x66, 0x6B, 0x32, 0x89, 0x80, 0x79, 0x1F, 0x30, 0x45, 0xE0, 0x7A, 0x18, 0x70, 0x78, 0x38, 0x61, + 0x7C, 0x79, 0x07, 0x02, 0xCD, 0xE0, 0xB4, 0x24, 0x92, 0x40, 0x18, 0x18, 0x3C, 0x24, 0x24, 0x7E, 0x42, 0x42, 0xC3, 0xFE, 0x08, + 0x20, 0xFE, 0x18, 0x61, 0xFC, 0xFA, 0x38, 0x61, 0xFA, 0x18, 0x61, 0xFC, 0xFE, 0x08, 0x20, 0x82, 0x08, 0x20, 0x80, 0x1F, 0x08, + 0x84, 0x42, 0x21, 0x10, 0x88, 0x44, 0x42, 0xFF, 0xC0, 0x60, 0x20, 0xFE, 0x08, 0x20, 0xFE, 0x08, 0x20, 0xFC, 0x88, 0xA4, 0x9A, + 0x87, 0xC1, 0xC1, 0xF1, 0xAD, 0x92, 0x88, 0x80, 0x7A, 0x18, 0x41, 0x38, 0x18, 0x61, 0x7C, 0x87, 0x0E, 0x2C, 0x59, 0x34, 0x68, + 0xE1, 0xC2, 0x28, 0x22, 0x1C, 0x38, 0xB1, 0x64, 0xD1, 0xA3, 0x87, 0x08, 0x8E, 0x6B, 0x38, 0xC2, 0x89, 0x22, 0x8C, 0x3E, 0x44, + 0x89, 0x12, 0x24, 0x58, 0xA1, 0xC2, 0xC3, 0xC3, 0xC3, 0xA5, 0xA5, 0xA5, 0x99, 0x99, 0x99, 0x83, 0x06, 0x0C, 0x1F, 0xF0, 0x60, + 0xC1, 0x82, 0x3C, 0x46, 0x83, 0x81, 0x81, 0x81, 0x81, 0xC2, 0x7C, 0xFF, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x82, 0xFA, 0x18, + 0x61, 0xFE, 0x08, 0x20, 0x80, 0x38, 0x8A, 0x0C, 0x08, 0x10, 0x20, 0xE3, 0x7C, 0xFE, 0x20, 0x40, 0x81, 0x02, 0x04, 0x08, 0x10, + 0xC2, 0x8D, 0x91, 0x63, 0x83, 0x04, 0x18, 0x20, 0x08, 0x1E, 0x32, 0xD1, 0x38, 0x8C, 0x4F, 0x2C, 0xFC, 0x08, 0x00, 0x87, 0x34, + 0x8C, 0x30, 0xC4, 0xB3, 0x84, 0x82, 0x82, 0x82, 0x82, 0x82, 0x82, 0x82, 0x82, 0xFF, 0x01, 0x01, 0x8E, 0x38, 0xE3, 0x8D, 0xF0, + 0xC3, 0x0C, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0xFF, 0x99, 0x4C, 0xA6, 0x53, 0x29, 0x94, 0xCA, 0x65, 0x32, 0xFF, + 0x80, 0x40, 0x20, 0xF0, 0x04, 0x01, 0x00, 0x40, 0x1F, 0x84, 0x21, 0x0C, 0x42, 0x1F, 0x00, 0x81, 0xC0, 0xE0, 0x70, 0x3F, 0xDC, + 0x2E, 0x17, 0x0B, 0xF9, 0x80, 0x82, 0x08, 0x20, 0xFE, 0x18, 0x61, 0xF8, 0x79, 0x8A, 0x18, 0x13, 0xE0, 0x60, 0xC2, 0x7C, 0x87, + 0x26, 0x39, 0x06, 0x41, 0xF0, 0x64, 0x19, 0x06, 0x63, 0x8F, 0x80, 0x7E, 0x18, 0x61, 0x7C, 0xD6, 0x71, 0x84, 0x79, 0x11, 0xD9, + 0xCD, 0xD0, 0x0D, 0xC4, 0x1E, 0x47, 0x1C, 0x51, 0x78, 0xF4, 0xBD, 0x29, 0xF8, 0xF8, 0x88, 0x88, 0x3C, 0x48, 0x91, 0x22, 0x5F, + 0xE0, 0x80, 0x79, 0x1F, 0xF0, 0x45, 0xE0, 0x92, 0x54, 0x38, 0x3C, 0x56, 0x93, 0x78, 0x23, 0x82, 0xCD, 0xE0, 0x9C, 0xEB, 0x5C, + 0xC4, 0x70, 0x27, 0x3A, 0xD7, 0x31, 0x9A, 0xCC, 0xA9, 0x7A, 0x52, 0x94, 0xE4, 0x8F, 0x3D, 0x6D, 0xA6, 0x90, 0x8C, 0x7F, 0x18, + 0xC4, 0x79, 0x1C, 0x71, 0x45, 0xE0, 0xFC, 0x63, 0x18, 0xC4, 0xFC, 0x63, 0x18, 0xFA, 0x10, 0x80, 0x79, 0x1C, 0x30, 0x45, 0xE0, + 0xF9, 0x08, 0x42, 0x10, 0x8A, 0x56, 0xA3, 0x10, 0x8C, 0x40, 0x04, 0x01, 0x07, 0xF9, 0x31, 0xC4, 0x71, 0x14, 0xC5, 0xFE, 0x04, + 0x01, 0x00, 0x40, 0x4B, 0x8C, 0x65, 0xE4, 0x8A, 0x28, 0xA2, 0x8B, 0xF0, 0x40, 0x99, 0x97, 0x11, 0x96, 0x59, 0x65, 0x97, 0xF0, + 0x95, 0x2A, 0x54, 0xA9, 0x5F, 0xC0, 0x80, 0xF0, 0x20, 0x78, 0x91, 0x23, 0xC0, 0x86, 0x1F, 0x63, 0x8F, 0xD0, 0x84, 0x3D, 0x18, + 0xF8, 0xF4, 0xDE, 0x19, 0xF8, 0x9E, 0xA2, 0xE1, 0xA1, 0xA2, 0x9E, 0xFC, 0x7E, 0xD4, 0xC4, +}; + +const GFXglyph FreeSans6pt8bCyrillicGlyphs[] PROGMEM = { + {0, 0, 0, 3, 0, 0}, // 0x20 ' ' + {3, 2, 9, 3, 1, -8}, // 0x21 '!' + {6, 3, 3, 4, 1, -8}, // 0x22 '"' + {8, 7, 8, 7, 0, -7}, // 0x23 '#' + {15, 6, 11, 7, 0, -8}, // 0x24 '$' + {24, 10, 9, 11, 0, -8}, // 0x25 '%' + {36, 6, 9, 8, 1, -8}, // 0x26 '&' + {43, 1, 3, 2, 1, -8}, // 0x27 ''' + {44, 2, 10, 4, 1, -7}, // 0x28 '(' + {47, 3, 11, 4, 0, -7}, // 0x29 ')' + {52, 3, 4, 5, 1, -8}, // 0x2A '*' + {54, 5, 6, 7, 1, -5}, // 0x2B '+' + {58, 1, 3, 3, 1, 0}, // 0x2C ',' + {59, 2, 1, 4, 1, -3}, // 0x2D '-' + {60, 1, 1, 3, 1, 0}, // 0x2E '.' + {61, 3, 8, 3, 0, -7}, // 0x2F '/' + {64, 5, 9, 7, 1, -8}, // 0x30 '0' + {70, 3, 9, 7, 1, -8}, // 0x31 '1' + {74, 6, 9, 7, 0, -8}, // 0x32 '2' + {81, 5, 9, 7, 1, -8}, // 0x33 '3' + {87, 6, 9, 7, 0, -8}, // 0x34 '4' + {94, 5, 9, 7, 1, -8}, // 0x35 '5' + {100, 5, 9, 7, 1, -8}, // 0x36 '6' + {106, 5, 9, 7, 1, -8}, // 0x37 '7' + {112, 6, 9, 7, 0, -8}, // 0x38 '8' + {119, 6, 9, 7, 0, -8}, // 0x39 '9' + {126, 2, 6, 3, 1, -5}, // 0x3A ':' + {128, 2, 8, 3, 1, -5}, // 0x3B ';' + {130, 5, 5, 7, 1, -4}, // 0x3C '<' + {134, 5, 3, 7, 1, -3}, // 0x3D '=' + {136, 5, 5, 7, 1, -4}, // 0x3E '>' + {140, 5, 9, 7, 1, -8}, // 0x3F '?' + {146, 11, 11, 12, 0, -8}, // 0x40 '@' + {162, 8, 9, 8, 0, -8}, // 0x41 'A' + {171, 6, 9, 8, 1, -8}, // 0x42 'B' + {178, 7, 9, 9, 1, -8}, // 0x43 'C' + {186, 7, 9, 9, 1, -8}, // 0x44 'D' + {194, 6, 9, 8, 1, -8}, // 0x45 'E' + {201, 6, 9, 7, 1, -8}, // 0x46 'F' + {208, 8, 9, 9, 1, -8}, // 0x47 'G' + {217, 7, 9, 9, 1, -8}, // 0x48 'H' + {225, 1, 9, 3, 1, -8}, // 0x49 'I' + {227, 5, 9, 6, 0, -8}, // 0x4A 'J' + {233, 7, 9, 8, 1, -8}, // 0x4B 'K' + {241, 5, 9, 7, 1, -8}, // 0x4C 'L' + {247, 8, 9, 10, 1, -8}, // 0x4D 'M' + {256, 7, 9, 9, 1, -8}, // 0x4E 'N' + {264, 9, 9, 9, 0, -8}, // 0x4F 'O' + {275, 6, 9, 8, 1, -8}, // 0x50 'P' + {282, 9, 9, 9, 0, -8}, // 0x51 'Q' + {293, 7, 9, 9, 1, -8}, // 0x52 'R' + {301, 6, 9, 8, 1, -8}, // 0x53 'S' + {308, 7, 9, 7, 0, -8}, // 0x54 'T' + {316, 7, 9, 9, 1, -8}, // 0x55 'U' + {324, 8, 9, 8, 0, -8}, // 0x56 'V' + {333, 11, 9, 11, 0, -8}, // 0x57 'W' + {346, 6, 9, 8, 1, -8}, // 0x58 'X' + {353, 8, 9, 8, 0, -8}, // 0x59 'Y' + {362, 7, 9, 7, 0, -8}, // 0x5A 'Z' + {370, 2, 12, 3, 1, -8}, // 0x5B '[' + {373, 3, 9, 3, 0, -8}, // 0x5C '\' + {377, 3, 12, 3, 0, -8}, // 0x5D ']' + {382, 4, 5, 6, 1, -8}, // 0x5E '^' + {385, 6, 1, 7, 0, 2}, // 0x5F '_' + {386, 2, 2, 4, 1, -8}, // 0x60 '`' + {387, 5, 6, 7, 1, -5}, // 0x61 'a' + {391, 5, 9, 7, 1, -8}, // 0x62 'b' + {397, 6, 6, 6, 0, -5}, // 0x63 'c' + {402, 6, 9, 7, 0, -8}, // 0x64 'd' + {409, 5, 6, 7, 1, -5}, // 0x65 'e' + {413, 3, 9, 3, 0, -8}, // 0x66 'f' + {417, 6, 9, 7, 0, -5}, // 0x67 'g' + {424, 5, 9, 7, 1, -8}, // 0x68 'h' + {430, 1, 9, 3, 1, -8}, // 0x69 'i' + {432, 2, 12, 3, 0, -8}, // 0x6A 'j' + {435, 5, 9, 6, 1, -8}, // 0x6B 'k' + {441, 1, 9, 3, 1, -8}, // 0x6C 'l' + {443, 8, 6, 10, 1, -5}, // 0x6D 'm' + {449, 5, 6, 7, 1, -5}, // 0x6E 'n' + {453, 6, 6, 7, 0, -5}, // 0x6F 'o' + {458, 5, 9, 7, 1, -5}, // 0x70 'p' + {464, 6, 9, 7, 0, -5}, // 0x71 'q' + {471, 3, 6, 4, 1, -5}, // 0x72 'r' + {474, 6, 6, 6, 0, -5}, // 0x73 's' + {479, 3, 8, 3, 0, -7}, // 0x74 't' + {482, 5, 6, 7, 1, -5}, // 0x75 'u' + {486, 6, 6, 6, 0, -5}, // 0x76 'v' + {491, 8, 6, 9, 0, -5}, // 0x77 'w' + {497, 4, 6, 6, 1, -5}, // 0x78 'x' + {500, 5, 9, 6, 0, -5}, // 0x79 'y' + {506, 5, 6, 6, 0, -5}, // 0x7A 'z' + {510, 2, 12, 4, 1, -8}, // 0x7B '{' + {513, 1, 12, 3, 1, -8}, // 0x7C '|' + {515, 3, 12, 4, 0, -8}, // 0x7D '}' + {520, 5, 2, 7, 1, -4}, // 0x7E '~' + {522, 6, 9, 8, 1, -8}, // + {529, 9, 11, 9, 0, -8}, // + {542, 6, 11, 7, 1, -10}, // + {551, 0, 0, 8, 0, 0}, // + {551, 4, 9, 5, 1, -8}, // + {556, 0, 0, 8, 0, 0}, // + {556, 0, 0, 8, 0, 0}, // + {556, 0, 0, 8, 0, 0}, // + {556, 0, 0, 8, 0, 0}, // + {556, 6, 8, 8, 1, -7}, // + {562, 0, 0, 8, 0, 0}, // + {562, 11, 9, 13, 1, -8}, // + {575, 0, 0, 8, 0, 0}, // + {575, 11, 9, 12, 1, -8}, // + {588, 6, 11, 8, 1, -10}, // + {597, 9, 9, 9, 0, -8}, // + {608, 7, 11, 9, 1, -8}, // + {618, 6, 11, 7, 0, -8}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 0, 0, 8, 0, 0}, // + {627, 9, 6, 10, 0, -5}, // + {634, 0, 0, 8, 0, 0}, // + {634, 9, 6, 10, 1, -5}, // + {641, 4, 8, 6, 1, -7}, // + {645, 6, 9, 7, 0, -8}, // + {652, 5, 7, 7, 1, -5}, // + {657, 0, 0, 8, 0, 0}, // + {657, 7, 11, 7, 0, -10}, // + {667, 5, 11, 6, 0, -7}, // + {674, 5, 9, 6, 0, -8}, // + {680, 0, 0, 8, 0, 0}, // + {680, 6, 10, 7, 1, -9}, // + {688, 0, 0, 8, 0, 0}, // + {688, 0, 0, 8, 0, 0}, // + {688, 6, 11, 8, 1, -10}, // + {697, 7, 9, 9, 1, -8}, // + {705, 0, 0, 8, 0, 0}, // + {705, 0, 0, 8, 0, 0}, // + {705, 2, 12, 3, 0, -8}, // + {708, 0, 0, 8, 0, 0}, // + {708, 0, 0, 8, 0, 0}, // + {708, 3, 11, 3, 0, -10}, // + {713, 0, 0, 8, 0, 0}, // + {713, 0, 0, 8, 0, 0}, // + {713, 1, 9, 3, 1, -8}, // + {715, 1, 9, 3, 1, -8}, // + {717, 3, 8, 5, 1, -7}, // + {720, 6, 9, 7, 1, -5}, // + {727, 0, 0, 8, 0, 0}, // + {727, 0, 0, 8, 0, 0}, // + {727, 6, 9, 7, 0, -8}, // + {734, 9, 9, 11, 1, -8}, // + {745, 6, 6, 6, 0, -5}, // + {750, 0, 0, 8, 0, 0}, // + {750, 0, 0, 8, 0, 0}, // + {750, 6, 9, 8, 1, -8}, // + {757, 6, 6, 6, 0, -5}, // + {762, 3, 9, 3, 0, -8}, // + {766, 8, 9, 8, 0, -8}, // + {775, 6, 9, 8, 1, -8}, // + {782, 6, 9, 8, 1, -8}, // + {789, 6, 9, 7, 1, -8}, // + {796, 9, 11, 10, 0, -8}, // + {809, 6, 9, 8, 1, -8}, // + {816, 9, 9, 11, 1, -8}, // + {827, 6, 9, 8, 1, -8}, // + {834, 7, 9, 9, 1, -8}, // + {842, 7, 11, 9, 1, -10}, // + {852, 6, 9, 8, 1, -8}, // + {859, 7, 9, 8, 0, -8}, // + {867, 8, 9, 10, 1, -8}, // + {876, 7, 9, 9, 1, -8}, // + {884, 8, 9, 10, 1, -8}, // + {893, 7, 9, 9, 1, -8}, // + {901, 6, 9, 8, 1, -8}, // + {908, 7, 9, 9, 1, -8}, // + {916, 7, 9, 7, 0, -8}, // + {924, 7, 9, 7, 0, -8}, // + {932, 9, 9, 10, 1, -8}, // + {943, 6, 9, 8, 1, -8}, // + {950, 8, 11, 9, 1, -8}, // + {961, 6, 9, 8, 1, -8}, // + {968, 8, 9, 10, 1, -8}, // + {977, 9, 11, 10, 1, -8}, // + {990, 10, 9, 10, 0, -8}, // + {1002, 9, 9, 10, 1, -8}, // + {1013, 6, 9, 8, 1, -8}, // + {1020, 7, 9, 9, 1, -8}, // + {1028, 10, 9, 12, 1, -8}, // + {1040, 6, 9, 8, 1, -8}, // + {1047, 6, 6, 7, 0, -5}, // + {1052, 6, 9, 7, 0, -8}, // + {1059, 5, 6, 6, 1, -5}, // + {1063, 4, 6, 5, 1, -5}, // + {1066, 7, 7, 7, 0, -5}, // + {1073, 6, 6, 7, 0, -5}, // + {1078, 8, 6, 9, 1, -5}, // + {1084, 6, 6, 6, 0, -5}, // + {1089, 5, 6, 7, 1, -5}, // + {1093, 5, 8, 7, 1, -7}, // + {1098, 4, 6, 6, 1, -5}, // + {1101, 5, 6, 6, 0, -5}, // + {1105, 6, 6, 7, 1, -5}, // + {1110, 5, 6, 7, 1, -5}, // + {1114, 6, 6, 7, 0, -5}, // + {1119, 5, 6, 7, 1, -5}, // + {1123, 5, 9, 7, 1, -5}, // + {1129, 6, 6, 6, 0, -5}, // + {1134, 5, 6, 5, 0, -5}, // + {1138, 5, 9, 6, 0, -5}, // + {1144, 10, 11, 10, 0, -7}, // + {1158, 5, 6, 6, 0, -5}, // + {1162, 6, 7, 7, 1, -5}, // + {1168, 4, 6, 6, 1, -5}, // + {1171, 6, 6, 8, 1, -5}, // + {1176, 7, 7, 9, 1, -5}, // + {1183, 7, 6, 8, 0, -5}, // + {1189, 6, 6, 8, 1, -5}, // + {1194, 5, 6, 6, 1, -5}, // + {1198, 5, 6, 6, 1, -5}, // + {1202, 8, 6, 9, 1, -5}, // + {1208, 5, 6, 7, 1, -5} // +}; + +const GFXfont FreeSans6pt8bCyrillic PROGMEM = {(uint8_t *)FreeSans6pt8bCyrillicBitmaps, (GFXglyph *)FreeSans6pt8bCyrillicGlyphs, + 0x20, 0xFF, 16}; diff --git a/src/graphics/niche/Fonts/README.md b/src/graphics/niche/Fonts/README.md new file mode 100644 index 000000000..e79927786 --- /dev/null +++ b/src/graphics/niche/Fonts/README.md @@ -0,0 +1,4 @@ +# NicheGraphics - Fonts + +A common area to store fonts which might be reused by different Niche Graphics UIs +In future, we may want to separate these by library (AdafruitGFX, u8g2, etc) diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp new file mode 100644 index 000000000..96ed18d58 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -0,0 +1,843 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./Applet.h" + +#include "rtc.h" + +using namespace NicheGraphics; + +InkHUD::AppletFont InkHUD::Applet::fontLarge; // General purpose font. Set by setDefaultFonts +InkHUD::AppletFont InkHUD::Applet::fontSmall; // General purpose font. Set by setDefaultFonts +constexpr float InkHUD::Applet::LOGO_ASPECT_RATIO; // Ratio of the Meshtastic logo + +InkHUD::Applet::Applet() : GFX(0, 0) +{ + // GFX is given initial dimensions of 0 + // The width and height will change dynamically, depending on Applet tiling + // If you're getting a "divide by zero error", consider it an assert: + // WindowManager should be the only one controlling the rendering +} + +// The raw pixel output generated by AdafruitGFX drawing +// Hand off to the applet's tile, which will in-turn pass to the window manager +void InkHUD::Applet::drawPixel(int16_t x, int16_t y, uint16_t color) +{ + // Only render pixels if they fall within user's cropped region + if (x >= cropLeft && x < (cropLeft + cropWidth) && y >= cropTop && y < (cropTop + cropHeight)) + assignedTile->handleAppletPixel(x, y, (Color)color); +} + +// Sets which tile the applet renders for +// Pixel output is passed to tile during render() +// This should only be called by Tile::assignApplet +void InkHUD::Applet::setTile(Tile *t) +{ + // If we're setting (not clearing), make sure the link is "reciprocal" + if (t) + assert(t->getAssignedApplet() == this); + + assignedTile = t; +} + +// Which tile will the applet render() to? +InkHUD::Tile *InkHUD::Applet::getTile() +{ + return assignedTile; +} + +void InkHUD::Applet::render() +{ + assert(assignedTile); // Ensure that we have a tile + assert(assignedTile->getAssignedApplet() == this); // Ensure that we have a reciprocal link with the tile + + wantRender = false; // Clear the flag set by requestUpdate + wantAutoshow = false; // If we're rendering now, it means our request was considered. It may or may not have been granted. + wantUpdateType = Drivers::EInk::UpdateTypes::UNSPECIFIED; // Our requested type has been considered by now. Tidy up. + + updateDimensions(); + resetDrawingSpace(); + onRender(); // Derived applet's drawing takes place here + + // If our tile is (or was) highlighted, to indicate a change in focus + if (Tile::highlightTarget == assignedTile) { + // Draw the highlight + if (!Tile::highlightShown) { + drawRect(0, 0, width(), height(), BLACK); + Tile::startHighlightTimeout(); + Tile::highlightShown = true; + } + + // Clear the highlight + else { + Tile::cancelHighlightTimeout(); + Tile::highlightShown = false; + Tile::highlightTarget = nullptr; + } + } +} + +// Does the applet want to render now? +// Checks whether the applet called requestUpdate() recently, in response to an event +bool InkHUD::Applet::wantsToRender() +{ + return wantRender; +} + +// Does the applet want to be moved to foreground before next render, to show new data? +// User specifies whether an applet has permission for this, using the on-screen menu +bool InkHUD::Applet::wantsToAutoshow() +{ + return wantAutoshow; +} + +// Which technique would this applet prefer that the display use to change the image? +Drivers::EInk::UpdateTypes InkHUD::Applet::wantsUpdateType() +{ + return wantUpdateType; +} + +// Get size of the applet's drawing space from its tile +void InkHUD::Applet::updateDimensions() +{ + assert(assignedTile); + WIDTH = assignedTile->getWidth(); + HEIGHT = assignedTile->getHeight(); + _width = WIDTH; + _height = HEIGHT; +} + +// Ensure that render() always starts with the same initial drawing config +void InkHUD::Applet::resetDrawingSpace() +{ + resetCrop(); // Allow pixel from any region of the applet to draw + setTextColor(BLACK); // Reset text params + setCursor(0, 0); + setTextWrap(false); + setFont(AppletFont()); // Restore the default AdafruitGFX font +} + +// Tell the window manager that we want to render now +// Applets should internally listen for events they are interested in, via MeshModule, CallbackObserver etc +// When an applet decides it has heard something important, and wants to redraw, it calls this method +// Once the window manager has given other applets a chance to process whatever event we just detected, +// it will run Applet::render(), which may draw our applet to screen, if it is shown (forgeround) +void InkHUD::Applet::requestUpdate(Drivers::EInk::UpdateTypes type) +{ + wantRender = true; + wantUpdateType = type; + WindowManager::getInstance()->requestUpdate(); +} + +// Ask window manager to move this applet to foreground at start of next render +// Users select which applets have permission for this using the on-screen menu +void InkHUD::Applet::requestAutoshow() +{ + wantAutoshow = true; +} + +// Called when an Applet begins running +// Active applets are considered "enabled" +// They should now listen for events, and request their own updates +// They may also be force rendered by the window manager at any time +// Applets can be activated at run-time through the on-screen menu +void InkHUD::Applet::activate() +{ + onActivate(); // Call derived class' handler + active = true; +} + +// Called when an Applet stop running +// Inactive applets are considered "disabled" +// They should not listen for events, process data +// They will not be rendered +// Applets can be deactivated at run-time through the on-screen menu +void InkHUD::Applet::deactivate() +{ + // If applet is still in foreground, run its onBackground code first + if (isForeground()) + sendToBackground(); + + // If applet is active, run its onDeactivate code first + if (isActive()) + onDeactivate(); // Derived class' handler + active = false; +} + +// Is the Applet running? +// Note: active / inactive is not related to background / foreground +// An inactive applet is *fully* disabled +bool InkHUD::Applet::isActive() +{ + return active; +} + +// Begin showing the Applet +// It will be rendered immediately to whichever tile it is assigned +// The window manager will also now honor requestUpdate() calls from this applet +void InkHUD::Applet::bringToForeground() +{ + if (!foreground) { + foreground = true; + onForeground(); // Run derived applet class' handler + } + + requestUpdate(); +} + +// Stop showing the Applet +// Calls to requestUpdate() will no longer be honored +// When one applet moves to background, another should move to foreground +void InkHUD::Applet::sendToBackground() +{ + if (foreground) { + foreground = false; + onBackground(); // Run derived applet class' handler + } +} + +// Is the applet currently displayed on a tile +bool InkHUD::Applet::isForeground() +{ + return foreground; +} + +// Limit drawing to a certain region of the applet +// Pixels outside this region will be discarded +void InkHUD::Applet::setCrop(int16_t left, int16_t top, uint16_t width, uint16_t height) +{ + cropLeft = left; + cropTop = top; + cropWidth = width; + cropHeight = height; +} + +// Allow drawing to any region of the Applet +// Reverses Applet::setCrop +void InkHUD::Applet::resetCrop() +{ + setCrop(0, 0, width(), height()); +} + +// Convert relative width to absolute width, in px +// X(0) is 0 +// X(0.5) is width() / 2 +// X(1) is width() +uint16_t InkHUD::Applet::X(float f) +{ + return width() * f; +} + +// Convert relative hight to absolute height, in px +// Y(0) is 0 +// Y(0.5) is height() / 2 +// Y(1) is height() +uint16_t InkHUD::Applet::Y(float f) +{ + return height() * f; +} + +// Print text, specifying the position of any edge / corner of the textbox +void InkHUD::Applet::printAt(int16_t x, int16_t y, const char *text, HorizontalAlignment ha, VerticalAlignment va) +{ + printAt(x, y, std::string(text), ha, va); +} + +// Print text, specifying the position of any edge / corner of the textbox +void InkHUD::Applet::printAt(int16_t x, int16_t y, std::string text, HorizontalAlignment ha, VerticalAlignment va) +{ + // Custom font + // - set with AppletFont::addSubstitution + // - find certain UTF8 chars + // - replace with glpyh from custom font (or suitable ASCII addSubstitution?) + getFont().applySubstitutions(&text); + + // We do still have to run getTextBounds to find the width + int16_t textOffsetX, textOffsetY; + uint16_t textWidth, textHeight; + getTextBounds(text.c_str(), 0, 0, &textOffsetX, &textOffsetY, &textWidth, &textHeight); + + int16_t cursorX = 0; + int16_t cursorY = 0; + + switch (ha) { + case LEFT: + cursorX = x - textOffsetX; + break; + case CENTER: + cursorX = (x - textOffsetX) - (textWidth / 2); + break; + case RIGHT: + cursorX = (x - textOffsetX) - textWidth; + break; + } + + // We're using a fixed line height (getFontDimensions), rather than sizing to text (getTextBounds) + // Note: the FontDimensions values for this are unsigned + + switch (va) { + case TOP: + cursorY = y + currentFont.heightAboveCursor(); + break; + case MIDDLE: + cursorY = (y + currentFont.heightAboveCursor()) - (currentFont.lineHeight() / 2); + break; + case BOTTOM: + cursorY = (y + currentFont.heightAboveCursor()) - currentFont.lineHeight(); + break; + } + + setCursor(cursorX, cursorY); + print(text.c_str()); +} + +// Set which font should be used for subsequent drawing +// This is AppletFont type, which is a wrapper for AdfruitGFX font, with some precalculated dimension data +void InkHUD::Applet::setFont(AppletFont f) +{ + GFX::setFont(f.gfxFont); + currentFont = f; +} + +// Get which font is currently being used for drawing +// This is AppletFont type, which is a wrapper for AdfruitGFX font, with some precalculated dimension data +InkHUD::AppletFont InkHUD::Applet::getFont() +{ + return currentFont; +} + +// Set two general-purpose fonts, which are reused by many applets +// Applets are also permitted to use other fonts, if they can justify the flash usage +void InkHUD::Applet::setDefaultFonts(AppletFont large, AppletFont small) +{ + Applet::fontSmall = small; + Applet::fontLarge = large; +} + +// Gets rendered width of a string +// Wrapper for getTextBounds +uint16_t InkHUD::Applet::getTextWidth(const char *text) +{ + + // We do still have to run getTextBounds to find the width + int16_t textOffsetX, textOffsetY; + uint16_t textWidth, textHeight; + getTextBounds(text, 0, 0, &textOffsetX, &textOffsetY, &textWidth, &textHeight); + + return textWidth; +} + +// Gets rendered width of a string +// Wrappe for getTextBounds +uint16_t InkHUD::Applet::getTextWidth(std::string text) +{ + getFont().applySubstitutions(&text); + + return getTextWidth(text.c_str()); +} + +// Evaluate SNR and RSSI to qualify signal strength at one of four discrete levels +// Roughly comparable to values used by the iOS app; +// I didn't actually go look up the code, just fit to a sample graphic I have of the iOS signal indicator +InkHUD::SignalStrength InkHUD::Applet::getSignalStrength(float snr, float rssi) +{ + uint8_t score = 0; + + // Give a score for the SNR + if (snr > -17.5) + score += 2; + else if (snr > -26.0) + score += 1; + + // Give a score for the RSSI + if (rssi > -115.0) + score += 3; + else if (rssi > -120.0) + score += 2; + else if (rssi > -126.0) + score += 1; + + // Combine scores, then give a result + if (score >= 5) + return SIGNAL_GOOD; + else if (score >= 4) + return SIGNAL_FAIR; + else if (score > 0) + return SIGNAL_BAD; + else + return SIGNAL_NONE; +} + +// Apply the standard "node id" formatting to a nodenum int: !0123abdc +std::string InkHUD::Applet::hexifyNodeNum(NodeNum num) +{ + // Not found in nodeDB, show a hex nodeid instead + char nodeIdHex[10]; + sprintf(nodeIdHex, "!%0x", num); // Convert to the typical "fixed width hex with !" format + return std::string(nodeIdHex); +} + +void InkHUD::Applet::printWrapped(int16_t left, int16_t top, uint16_t width, std::string text) +{ + // Custom font glyphs + // - set with AppletFont::addSubstitution + // - find certain UTF8 chars + // - replace with glpyh from custom font (or suitable ASCII addSubstitution?) + getFont().applySubstitutions(&text); + + // Place the AdafruitGFX cursor to suit our "top" coord + setCursor(left, top + getFont().heightAboveCursor()); + + // How wide a space character is + // Used when simulating print, for dimensioning + // Works around issues where getTextDimensions() doesn't account for whitespace + const uint8_t wSp = getFont().widthBetweenWords(); + + // Move through our text, character by character + uint16_t wordStart = 0; + for (uint16_t i = 0; i < text.length(); i++) { + + // Found: end of word (split by spaces or newline) + // Also handles end of string + if (text[i] == ' ' || text[i] == '\n' || i == text.length() - 1) { + // Isolate this word + uint16_t wordLength = (i - wordStart) + 1; // Plus one. Imagine: "a". End - Start is 0, but length is 1 + std::string word = text.substr(wordStart, wordLength); + wordStart = i + 1; // Next word starts *after* the space + + // If word is terminated by a newline char, don't actually print it. + // We'll manually add a new line later + if (word.back() == '\n') + word.pop_back(); + + // Measure the word, in px + int16_t l, t; + uint16_t w, h; + getTextBounds(word.c_str(), getCursorX(), getCursorY(), &l, &t, &w, &h); + + // Word is short + if (w < width) { + // Word fits on current line + if ((l + w + wSp) < left + width) + print(word.c_str()); + + // Word doesn't fit on current line + else { + setCursor(left, getCursorY() + getFont().lineHeight()); // Newline + print(word.c_str()); + } + } + + // Word is really long + // (wider than applet) + else { + // Horribly inefficient: + // Rather than working directly with the glyph sizes, + // we're going to run everything through getTextBounds as a c-string of length 1 + // This is because AdafruitGFX has special internal handling for their legacy 6x8 font, + // which would be a pain to add manually here. + // These super-long strings probably don't come up often so we can maybe tolerate this. + + // Todo: rewrite making use of AdafruitGFX native text wrapping + char cstr[] = {0, 0}; + int16_t l, t; + uint16_t w, h; + for (uint16_t c = 0; c < word.length(); c++) { + // Shove next char into a c string + cstr[0] = word[c]; + getTextBounds(cstr, getCursorX(), getCursorY(), &l, &t, &w, &h); + + // Manual newline, if next character will spill beyond screen edge + if ((l + w) > left + width) + setCursor(left, getCursorY() + getFont().lineHeight()); + + // Print next character + print(word[c]); + } + } + } + + // If word was terminated by a newline char, manually add the new line now + if (text[i] == '\n') { + setCursor(left, getCursorY() + getFont().lineHeight()); // Manual newline + wordStart = i + 1; // New word begins after the newline. Otherwise print will add an *extra* line + } + } +} + +// Simulate running printWrapped, to determine how tall the block of text will be. +// This is a wasteful way of handling things. Maybe some way to optimize in future? +uint32_t InkHUD::Applet::getWrappedTextHeight(int16_t left, uint16_t width, std::string text) +{ + // Cache the current crop region + int16_t cL = cropLeft; + int16_t cT = cropTop; + uint16_t cW = cropWidth; + uint16_t cH = cropHeight; + + setCrop(-1, -1, 0, 0); // Set crop to temporarily discard all pixels + printWrapped(left, 0, width, text); // Simulate only - no pixels drawn + + // Restore previous crop region + cropLeft = cL; + cropTop = cT; + cropWidth = cW; + cropHeight = cH; + + // Note: printWrapped() offsets the initial cursor position by heightAboveCursor() val, + // so we need to account for that when determining the height + return (getCursorY() + getFont().heightBelowCursor()); +} + +// Fill a region with sparse diagonal lines, to create a pseudo-translucent fill +void InkHUD::Applet::hatchRegion(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t spacing, Color color) +{ + // Cache the currently cropped region + int16_t oldCropL = cropLeft; + int16_t oldCropT = cropTop; + uint16_t oldCropW = cropWidth; + uint16_t oldCropH = cropHeight; + + setCrop(x, y, w, h); + + // Draw lines starting along the top edge, every few px + for (int16_t ix = x; ix < x + w; ix += spacing) { + for (int16_t i = 0; i < w || i < h; i++) { + drawPixel(ix + i, y + i, color); + } + } + + // Draw lines starting along the left edge, every few px + for (int16_t iy = y; iy < y + h; iy += spacing) { + for (int16_t i = 0; i < w || i < h; i++) { + drawPixel(x + i, iy + i, color); + } + } + + // Restore any previous crop + // If none was set, this will clear + cropLeft = oldCropL; + cropTop = oldCropT; + cropWidth = oldCropW; + cropHeight = oldCropH; +} + +// Get a human readable time representation of an epoch time (seconds since 1970) +// If time is invalid, this will be an empty string +std::string InkHUD::Applet::getTimeString(uint32_t epochSeconds) +{ +#ifdef BUILD_EPOCH + constexpr uint32_t validAfterEpoch = BUILD_EPOCH - (SEC_PER_DAY * 30 * 6); // 6 Months prior to build +#else + constexpr uint32_t validAfterEpoch = 1727740800 - (SEC_PER_DAY * 30 * 6); // 6 Months prior to October 1, 2024 12:00:00 AM GMT +#endif + + uint32_t epochNow = getValidTime(RTCQuality::RTCQualityDevice, true); + + int32_t daysAgo = (epochNow - epochSeconds) / SEC_PER_DAY; + int32_t hoursAgo = (epochNow - epochSeconds) / SEC_PER_HOUR; + + // Times are invalid: rtc is much older than when code was built + // Don't give any human readable string + if (epochNow <= validAfterEpoch) { + LOG_DEBUG("RTC prior to buildtime"); + return ""; + } + + // Times are invalid: argument time is significantly ahead of RTC + // Don't give any human readable string + if (daysAgo < -2) { + LOG_DEBUG("RTC in future"); + return ""; + } + + // Times are probably invalid: more than 6 months ago + if (daysAgo > 6 * 30) { + LOG_DEBUG("RTC val > 6 months old"); + return ""; + } + + if (daysAgo > 1) + return to_string(daysAgo) + " days ago"; + + else if (hoursAgo > 18) + return "Yesterday"; + + else { + + uint32_t hms = epochSeconds % SEC_PER_DAY; + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + // Tear apart hms into h:m + uint32_t hour = hms / SEC_PER_HOUR; + uint32_t min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + + // Format the clock string + char clockStr[11]; + sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12 : hour % 12), min, hour > 11 ? "PM" : "AM"); + + return clockStr; + } +} + +// If no argument specified, get time string for the current RTC time +std::string InkHUD::Applet::getTimeString() +{ + return getTimeString(getValidTime(RTCQuality::RTCQualityDevice, true)); +} + +// Calculate how many nodes have been seen within our preferred window of activity +// This period is set by user, via the menu +// Todo: optimize to calculate once only per WindowManager::render +uint16_t InkHUD::Applet::getActiveNodeCount() +{ + // Don't even try to count nodes if RTC isn't set + // The last heard values in nodedb will be incomprehensible + if (getRTCQuality() == RTCQualityNone) + return 0; + + uint16_t count = 0; + + // For each node in db + for (uint16_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + + // Check if heard recently, and not our own node + if (sinceLastSeen(node) < settings.recentlyActiveSeconds && node->num != nodeDB->getNodeNum()) + count++; + } + + return count; +} + +// Get an abbreviated, human readable, distance string +// Honors config.display.units, to offer both metric and imperial +std::string InkHUD::Applet::localizeDistance(uint32_t meters) +{ + constexpr float FEET_PER_METER = 3.28084; + constexpr uint16_t FEET_PER_MILE = 5280; + + // Resulting string + std::string localized; + + // Imeperial + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + uint32_t feet = meters * FEET_PER_METER; + // Distant (miles, rounded) + if (feet > FEET_PER_MILE / 2) { + localized += to_string((uint32_t)roundf(feet / FEET_PER_MILE)); + localized += "mi"; + } + // Nearby (feet) + else { + localized += to_string(feet); + localized += "ft"; + } + } + + // Metric + else { + // Distant (kilometers, rounded) + if (meters >= 500) { + localized += to_string((uint32_t)roundf(meters / 1000.0)); + localized += "km"; + } + // Nearby (meters) + else { + localized += to_string(meters); + localized += "m"; + } + } + + return localized; +} + +void InkHUD::Applet::printThick(int16_t xCenter, int16_t yCenter, std::string text, uint8_t thicknessX, uint8_t thicknessY) +{ + // How many times to draw along x axis + int16_t xStart; + int16_t xEnd; + switch (thicknessX) { + case 0: + assert(false); + case 1: + xStart = xCenter; + xEnd = xCenter; + break; + case 2: + xStart = xCenter; + xEnd = xCenter + 1; + break; + default: + xStart = xCenter - (thicknessX / 2); + xEnd = xCenter + (thicknessX / 2); + } + + // How many times to draw along Y axis + int16_t yStart; + int16_t yEnd; + switch (thicknessY) { + case 0: + assert(false); + case 1: + yStart = yCenter; + yEnd = yCenter; + break; + case 2: + yStart = yCenter; + yEnd = yCenter + 1; + break; + default: + yStart = yCenter - (thicknessY / 2); + yEnd = yCenter + (thicknessY / 2); + } + + // Print multiple times, overlapping + for (int16_t x = xStart; x <= xEnd; x++) { + for (int16_t y = yStart; y <= yEnd; y++) { + printAt(x, y, text, CENTER, MIDDLE); + } + } +} + +// Allow this applet to suppress notifications +// Asked before a notification is shown via the NotificationApplet +// An applet might want to suppress a notification if the applet itself already displays this info +// Example: AllMessageApplet should not approve notifications for messages, if it is in foreground +bool InkHUD::Applet::approveNotification(InkHUD::Notification &n) +{ + // By default, no objection + return true; +} + +// Draw the standard header, used by most Applets +void InkHUD::Applet::drawHeader(std::string text) +{ + setFont(fontSmall); + + // Y position for divider + // - between header text and messages + constexpr int16_t padDivH = 2; + const int16_t headerDivY = padDivH + fontSmall.lineHeight() + padDivH - 1; + + // Print header + printAt(0, padDivH, text); + + // Divider + // - below header text: separates message + // - above header text: separates other applets + for (int16_t x = 0; x < width(); x += 2) { + drawPixel(x, 0, BLACK); + drawPixel(x, headerDivY, BLACK); // Dotted 50% + } +} + +// Get the height of the standard applet header +// This will vary, depending on font +// Applets use this value to avoid drawing overtop the header +uint16_t InkHUD::Applet::getHeaderHeight() +{ + // Y position for divider + // - between header text and messages + constexpr int16_t padDivH = 2; + const int16_t headerDivY = padDivH + fontSmall.lineHeight() + padDivH - 1; + + return headerDivY + 1; // "Plus one": height is always one more than Y position +} + +// "Scale to fit": width of Meshtastic logo to fit given region, maintaining aspect ratio +uint16_t InkHUD::Applet::getLogoWidth(uint16_t limitWidth, uint16_t limitHeight) +{ + // Determine whether we're limited by width or height + // Makes sure we draw the logo as large as possible, within the specified region, + // while still maintaining correct aspect ratio + if (limitWidth > limitHeight * LOGO_ASPECT_RATIO) + return limitHeight * LOGO_ASPECT_RATIO; + else + return limitWidth; +} + +// "Scale to fit": height of Meshtastic logo to fit given region, maintaining aspect ratio +uint16_t InkHUD::Applet::getLogoHeight(uint16_t limitWidth, uint16_t limitHeight) +{ + // Determine whether we're limited by width or height + // Makes sure we draw the logo as large as possible, within the specified region, + // while still maintaining correct aspect ratio + if (limitHeight > limitWidth / LOGO_ASPECT_RATIO) + return limitWidth / LOGO_ASPECT_RATIO; + else + return limitHeight; +} + +// Draw a scalable Meshtastic logo +// Make sure to provide dimensions which have the correct aspect ratio (~2) +// Three paths, drawn thick using quads, with one corner "radiused" +void InkHUD::Applet::drawLogo(int16_t centerX, int16_t centerY, uint16_t width, uint16_t height) +{ + struct Point { + int x; + int y; + }; + typedef Point Distance; + + int16_t logoTh = width * 0.068; // Thickness scales with width. Measured from logo at meshtastic.org. + int16_t logoL = centerX - (width / 2) + (logoTh / 2); + int16_t logoT = centerY - (height / 2) + (logoTh / 2); + int16_t logoW = width - logoTh; + int16_t logoH = height - logoTh; + int16_t logoR = logoL + logoW - 1; + int16_t logoB = logoT + logoH - 1; + + // Points for paths (a, b, and c) + Point a1 = {map(0, 0, 3, logoL, logoR), logoB}; + Point a2 = {map(1, 0, 3, logoL, logoR), logoT}; + Point b1 = {map(1, 0, 3, logoL, logoR), logoB}; + Point b2 = {map(2, 0, 3, logoL, logoR), logoT}; + Point c1 = {map(2, 0, 3, logoL, logoR), logoT}; + Point c2 = {map(3, 0, 3, logoL, logoR), logoB}; + + // Find right-angle to the path + // Used to thicken the single pixel paths + Distance deltaA = {abs(a2.x - a1.x), abs(a2.y - a1.y)}; + float angle = tanh((float)deltaA.y / deltaA.x); + + // Distance {at right angle from the paths), which will give corners for our "quads" + // The distance is unsigned. We will vary the signedness of the x and y components to suit the path and corner + Distance fromPath; + fromPath.x = cos(radians(90) - angle) * logoTh * 0.5; + fromPath.y = sin(radians(90) - angle) * logoTh * 0.5; + + // Make the path thick: path a becomes quad a + Point aq1{a1.x - fromPath.x, a1.y - fromPath.y}; + Point aq2{a2.x - fromPath.x, a2.y - fromPath.y}; + Point aq3{a2.x + fromPath.x, a2.y + fromPath.y}; + Point aq4{a1.x + fromPath.x, a1.y + fromPath.y}; + fillTriangle(aq1.x, aq1.y, aq2.x, aq2.y, aq3.x, aq3.y, BLACK); + fillTriangle(aq1.x, aq1.y, aq3.x, aq3.y, aq4.x, aq4.y, BLACK); + + // Make the path thick: path b becomes quad b + Point bq1{b1.x - fromPath.x, b1.y - fromPath.y}; + Point bq2{b2.x - fromPath.x, b2.y - fromPath.y}; + Point bq3{b2.x + fromPath.x, b2.y + fromPath.y}; + Point bq4{b1.x + fromPath.x, b1.y + fromPath.y}; + fillTriangle(bq1.x, bq1.y, bq2.x, bq2.y, bq3.x, bq3.y, BLACK); + fillTriangle(bq1.x, bq1.y, bq3.x, bq3.y, bq4.x, bq4.y, BLACK); + + // Make the path hick: path c becomes quad c + Point cq1{c1.x - fromPath.x, c1.y + fromPath.y}; + Point cq2{c2.x - fromPath.x, c2.y + fromPath.y}; + Point cq3{c2.x + fromPath.x, c2.y - fromPath.y}; + Point cq4{c1.x + fromPath.x, c1.y - fromPath.y}; + fillTriangle(cq1.x, cq1.y, cq2.x, cq2.y, cq3.x, cq3.y, BLACK); + fillTriangle(cq1.x, cq1.y, cq3.x, cq3.y, cq4.x, cq4.y, BLACK); + + // Radius the intersection of quad b and quad c + // Don't attempt if logo is tiny + if (logoTh > 3) { + // The radius for the cap *should* be the same as logoTh, but it's not, due to accumulated rounding + // We get better results just rederiving it + int16_t capRad = sqrt(pow(fromPath.x, 2) + pow(fromPath.y, 2)); + fillCircle(b2.x, b2.y, capRad, BLACK); + } +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h new file mode 100644 index 000000000..30c1bdcdc --- /dev/null +++ b/src/graphics/niche/InkHUD/Applet.h @@ -0,0 +1,234 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Base class for InkHUD applets + Must be overriden + + An applet is one "program" which may show info on the display. + + =================================== + Preliminary notes, for the curious + =================================== + + (This info to be streamlined, and moved to a more official documentation) + + User Applets vs System Applets + ------------------------------- + + There are either "User Applets", or "System Applets". + This concept is only for our understanding; as far at the code is concerned, both are just "Applets" + + User applets are the "normal" applets. + User applets are applets like "AllMessageApplet", or "MapApplet". + User applets may be enabled / disabled by user, via the on-screen menu. + Incorporating new UserApplets is easy: just add them during setupNicheGraphics + If a UserApplet is not added during setupNicheGraphics, it will not be built. + The set of available UserApplets is allowed to vary from device to device. + + + Examples of system applets include "NotificationApplet" and "MenuApplet". + For their own reasons, system applets each require some amount of special handling. + + Drawing + -------- + + *All* drawing must be performed by an Applet. + Applets implement the onRender() method, where all drawing takes place. + Applets are told how wide and tall they are, and are expected to draw to suit this size. + When an applet draws, it uses co-ordinates in "Applet Space": between 0 and applet width/height. + + Event-driven rendering + ----------------------- + + Applets don't render unless something on the display needs to change. + An applet is expected to determine for itself when it has new info to display. + It should interact with the firmware via the MeshModule API, via Observables, etc. + Please don't directly add hooks throughout the existing firmware code. + + When an applet decides it would like to update the display, it should call requestUpdate() + The WindowManager will shortly call the onRender() method for all affected applets + + An Applet may be unexpectedly asked to render at any point in time. + + Applets should cache their data, but not their pixel output: they should re-render when onRender runs. + An Applet's dimensions are not know until onRender is called, so pre-rendering of UI elements is prohibited. + + Tiles + ----- + + Applets are assigned to "Tiles". + Assigning an applet to a tile creates a reciprocal link between the two. + When an applet renders, it passes pixels to its tile. + The tile translates these to the correct position, to be placed into the fullscreen framebuffer. + User applets don't get to choose their own tile; the multiplexing is handled by the WindowManager. + System applets might do strange things though. + + Foreground and Background + ------------------------- + + The user can cycle between applets by short-pressing the user button. + Any applets which are currently displayed on the display are "foreground". + When the user button is short pressed, and an applet is hidden, it becomes "background". + + Although the WindowManager will not render background applets, they should still collect data, + so they are ready to display when they are brought to foreground again. + Even if they are in background, Applets should still request updates when an event affects them, + as the user may have given them permission to "autoshow"; bringing themselves foreground automatically + + Applets can implement the onForeground and onBackground methods to handle this change in state. + They can also check their state by calling isForeground() at any time. + + Active and Inactive + ------------------- + + The user can select which applets are available, using the onscreen applet selection menu. + Applets which are enabled in this menu are "active"; otherwise they are "inactive". + + An inactive applet is expected not collect data; not to consume resources. + Applets are activated at boot, or when enabled via the menu. + They are deactivated at shutdown, or when disabled via the menu. + + Applets can implement the onActivation and onDeactivation methods to handle this change in state. + +*/ + +#pragma once + +#include "configuration.h" + +#include + +#include "./AppletFont.h" +#include "./Applets/System/Notification/Notification.h" +#include "./Tile.h" +#include "./Types.h" +#include "./WindowManager.h" +#include "graphics/niche/Drivers/EInk/EInk.h" + +namespace NicheGraphics::InkHUD +{ + +using NicheGraphics::Drivers::EInk; +using std::to_string; + +class Tile; +class WindowManager; + +class Applet : public GFX +{ + public: + Applet(); + + void setTile(Tile *t); // Applets draw via a tile (for multiplexing) + Tile *getTile(); + + void render(); + bool wantsToRender(); // Check whether applet wants to render + bool wantsToAutoshow(); // Check whether applets wants to become foreground, to show new data, if permitted + Drivers::EInk::UpdateTypes wantsUpdateType(); // Check which display update type the applet would prefer + void updateDimensions(); // Get current size from tile + void resetDrawingSpace(); // Makes sure every render starts with same parameters + + // Change the applet's state + + void activate(); + void deactivate(); + void bringToForeground(); + void sendToBackground(); + + // Info about applet's state + + bool isActive(); + bool isForeground(); + + // Allow derived applets to handle changes in state + + virtual void onRender() = 0; // All drawing happens here + virtual void onActivate() {} + virtual void onDeactivate() {} + virtual void onForeground() {} + virtual void onBackground() {} + virtual void onShutdown() {} + virtual void onButtonShortPress() {} // For use by System Applets only + virtual void onButtonLongPress() {} // For use by System Applets only + virtual void onLockAvailable() {} // For use by System Applets only + + virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification + + static void setDefaultFonts(AppletFont large, AppletFont small); // Set the general purpose fonts + static uint16_t getHeaderHeight(); // How tall is the "standard" applet header + + const char *name = nullptr; // Shown in applet selection menu + + protected: + // Place a single pixel. All drawing methods output through here + void drawPixel(int16_t x, int16_t y, uint16_t color) override; + + // Tell WindowManager to update display + void requestUpdate(EInk::UpdateTypes type = EInk::UpdateTypes::UNSPECIFIED); + + // Ask for applet to be moved to foreground + void requestAutoshow(); + + uint16_t X(float f); // Map applet width, mapped from 0 to 1.0 + uint16_t Y(float f); // Map applet height, mapped from 0 to 1.0 + void setCrop(int16_t left, int16_t top, uint16_t width, uint16_t height); // Ignore pixels drawn outside a certain region + void resetCrop(); // Removes setCrop() + + void setFont(AppletFont f); + AppletFont getFont(); + + uint16_t getTextWidth(std::string text); + uint16_t getTextWidth(const char *text); + + void printAt(int16_t x, int16_t y, const char *text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP); + void printAt(int16_t x, int16_t y, std::string text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP); + void printThick(int16_t xCenter, int16_t yCenter, std::string text, uint8_t thicknessX, uint8_t thicknessY); + + // Print text, with per-word line wrapping + void printWrapped(int16_t left, int16_t top, uint16_t width, std::string text); + uint32_t getWrappedTextHeight(int16_t left, uint16_t width, std::string text); + + void hatchRegion(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t spacing, Color color); // Fill with sparse lines + void drawHeader(std::string text); // Draw the standard applet header + + static constexpr float LOGO_ASPECT_RATIO = 1.9; // Width:Height for drawing the Meshtastic logo + uint16_t getLogoWidth(uint16_t limitWidth, uint16_t limitHeight); // Size Meshtastic logo to fit within region + uint16_t getLogoHeight(uint16_t limitWidth, uint16_t limitHeight); // Size Meshtastic logo to fit within region + void drawLogo(int16_t centerX, int16_t centerY, uint16_t width, uint16_t height); // Draw the meshtastic logo + + std::string hexifyNodeNum(NodeNum num); + SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value + std::string getTimeString(uint32_t epochSeconds); // Human readable + std::string getTimeString(); // Current time, human readable + uint16_t getActiveNodeCount(); // Duration determined by user, in onscreen menu + std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric + + static AppletFont fontSmall, fontLarge; // General purpose fonts, used cross-applet + + private: + Tile *assignedTile = nullptr; // Rendered pixels are fed into a Tile object, which translates them, then passes to WM + bool active = false; // Has the user enabled this applet (at run-time)? + bool foreground = false; // Is the applet currently drawn on a tile? + + bool wantRender = false; // In some situations, checked by WindowManager when updating, to skip unneeded redrawing. + bool wantAutoshow = false; // Does the applet have new data it would like to display in foreground? + NicheGraphics::Drivers::EInk::UpdateTypes wantUpdateType = + NicheGraphics::Drivers::EInk::UpdateTypes::UNSPECIFIED; // Which update method we'd prefer when redrawing the display + + using GFX::setFont; // Make sure derived classes use AppletFont instead of AdafruitGFX fonts directly + using GFX::setRotation; // Block setRotation calls. Rotation is handled globally by WindowManager. + + AppletFont currentFont; // As passed to setFont + + // As set by setCrop + int16_t cropLeft; + int16_t cropTop; + uint16_t cropWidth; + uint16_t cropHeight; +}; + +}; // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/AppletFont.cpp b/src/graphics/niche/InkHUD/AppletFont.cpp new file mode 100644 index 000000000..bee9d33e6 --- /dev/null +++ b/src/graphics/niche/InkHUD/AppletFont.cpp @@ -0,0 +1,208 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./AppletFont.h" + +using namespace NicheGraphics; + +InkHUD::AppletFont::AppletFont() +{ + // Default constructor uses the in-built AdafruitGFX font +} + +InkHUD::AppletFont::AppletFont(const GFXfont &adafruitGFXFont) : gfxFont(&adafruitGFXFont) +{ + // AdafruitGFX fonts are drawn relative to a "cursor line"; + // they print as if the glyphs resting on the line of piece of ruled paper. + // The glyphs also each have a different height. + + // To simplify drawing, we will scan the entire font now, and determine an appropriate height for a line of text + // We also need to know where that "cursor line" sits inside this "line height"; + // we need this additional info in order to align text by top-left, bottom-right, etc + + // AdafruitGFX fonts do declare a line-height, but this seems to include a certain amount of padding, + // which we'd rather not deal with. If we want padding, we'll add it manually. + + // Scan each glyph in the AdafruitGFX font + for (uint16_t i = 0; i <= (gfxFont->last - gfxFont->first); i++) { + uint8_t glyphHeight = gfxFont->glyph[i].height; // Height of glyph + this->height = max(this->height, glyphHeight); // Store if it's a new max + + // Calculate how far the glyph rises the cursor line + // Store if new max value + // Caution: signed and unsigned types + int8_t glyphAscender = 0 - gfxFont->glyph[i].yOffset; + if (glyphAscender > 0) + this->ascenderHeight = max(this->ascenderHeight, (uint8_t)glyphAscender); + } + + // Determine how far characters may hang "below the line" + descenderHeight = height - ascenderHeight; + + // Find how far the cursor advances when we "print" a space character + spaceCharWidth = gfxFont->glyph[(uint8_t)' ' - gfxFont->first].xAdvance; +} + +uint8_t InkHUD::AppletFont::lineHeight() +{ + return this->height; +} + +// AdafruitGFX fonts print characters so that they nicely on an imaginary line (think: ruled paper). +// This value is the height of the font, above that imaginary line. +// Used to calculate the true height of the font +uint8_t InkHUD::AppletFont::heightAboveCursor() +{ + return this->ascenderHeight; +} + +// AdafruitGFX fonts print characters so that they nicely on an imaginary line (think: ruled paper). +// This value is the height of the font, below that imaginary line. +// Used to calculate the true height of the font +uint8_t InkHUD::AppletFont::heightBelowCursor() +{ + return this->descenderHeight; +} + +// Width of the space character +// Used with Applet::printWrapped +uint8_t InkHUD::AppletFont::widthBetweenWords() +{ + return this->spaceCharWidth; +} + +// Add to the list of substituted glyphs +// This "find and replace" operation will be run before text is printed +// Used to swap out UTF8 special characters, either with a custom font, or with a suitable ASCII approximation +void InkHUD::AppletFont::addSubstitution(const char *from, const char *to) +{ + substitutions.push_back({.from = from, .to = to}); +} + +// Run all registered subtitutions on a string +// Used to swap out UTF8 special chars +void InkHUD::AppletFont::applySubstitutions(std::string *text) +{ + // For each substitution + for (Substitution s : substitutions) { + + // Find and replace + // - search for Substitution::from + // - replace with Subsitution::to + size_t i = text->find(s.from); + while (i != std::string::npos) { + text->replace(i, strlen(s.from), s.to); + i = text->find(s.from, i); // Continue looking from last position + } + } +} + +// Apply a set of substitutions which remap UTF8 for a Windows-1251 font +// Windows-1251 is an 8-bit character encoding, designed to cover languages that use the Cyrillic script +void InkHUD::AppletFont::addSubstitutionsWin1251() +{ + addSubstitution("Ђ", "\x80"); + addSubstitution("Ѓ", "\x81"); + addSubstitution("ѓ", "\x83"); + addSubstitution("€", "\x88"); + addSubstitution("Љ", "\x8A"); + addSubstitution("Њ", "\x8C"); + addSubstitution("Ќ", "\x8D"); + addSubstitution("Ћ", "\x8E"); + addSubstitution("Џ", "\x8F"); + + addSubstitution("ђ", "\x90"); + addSubstitution("љ", "\x9A"); + addSubstitution("њ", "\x9C"); + addSubstitution("ќ", "\x9D"); + addSubstitution("ћ", "\x9E"); + addSubstitution("џ", "\x9F"); + + addSubstitution("Ў", "\xA1"); + addSubstitution("ў", "\xA2"); + addSubstitution("Ј", "\xA3"); + addSubstitution("Ґ", "\xA5"); + addSubstitution("Ё", "\xA8"); + addSubstitution("Є", "\xAA"); + addSubstitution("Ї", "\xAF"); + + addSubstitution("І", "\xB2"); + addSubstitution("і", "\xB3"); + addSubstitution("ґ", "\xB4"); + addSubstitution("ё", "\xB8"); + addSubstitution("№", "\xB9"); + addSubstitution("є", "\xBA"); + addSubstitution("ј", "\xBC"); + addSubstitution("Ѕ", "\xBD"); + addSubstitution("ѕ", "\xBE"); + addSubstitution("ї", "\xBF"); + + addSubstitution("А", "\xC0"); + addSubstitution("Б", "\xC1"); + addSubstitution("В", "\xC2"); + addSubstitution("Г", "\xC3"); + addSubstitution("Д", "\xC4"); + addSubstitution("Е", "\xC5"); + addSubstitution("Ж", "\xC6"); + addSubstitution("З", "\xC7"); + addSubstitution("И", "\xC8"); + addSubstitution("Й", "\xC9"); + addSubstitution("К", "\xCA"); + addSubstitution("Л", "\xCB"); + addSubstitution("М", "\xCC"); + addSubstitution("Н", "\xCD"); + addSubstitution("О", "\xCE"); + addSubstitution("П", "\xCF"); + + addSubstitution("Р", "\xD0"); + addSubstitution("С", "\xD1"); + addSubstitution("Т", "\xD2"); + addSubstitution("У", "\xD3"); + addSubstitution("Ф", "\xD4"); + addSubstitution("Х", "\xD5"); + addSubstitution("Ц", "\xD6"); + addSubstitution("Ч", "\xD7"); + addSubstitution("Ш", "\xD8"); + addSubstitution("Щ", "\xD9"); + addSubstitution("Ъ", "\xDA"); + addSubstitution("Ы", "\xDB"); + addSubstitution("Ь", "\xDC"); + addSubstitution("Э", "\xDD"); + addSubstitution("Ю", "\xDE"); + addSubstitution("Я", "\xDF"); + + addSubstitution("а", "\xE0"); + addSubstitution("б", "\xE1"); + addSubstitution("в", "\xE2"); + addSubstitution("г", "\xE3"); + addSubstitution("д", "\xE4"); + addSubstitution("е", "\xE5"); + addSubstitution("ж", "\xE6"); + addSubstitution("з", "\xE7"); + addSubstitution("и", "\xE8"); + addSubstitution("й", "\xE9"); + addSubstitution("к", "\xEA"); + addSubstitution("л", "\xEB"); + addSubstitution("м", "\xEC"); + addSubstitution("н", "\xED"); + addSubstitution("о", "\xEE"); + addSubstitution("п", "\xEF"); + + addSubstitution("р", "\xF0"); + addSubstitution("с", "\xF1"); + addSubstitution("т", "\xF2"); + addSubstitution("у", "\xF3"); + addSubstitution("ф", "\xF4"); + addSubstitution("х", "\xF5"); + addSubstitution("ц", "\xF6"); + addSubstitution("ч", "\xF7"); + addSubstitution("ш", "\xF8"); + addSubstitution("щ", "\xF9"); + addSubstitution("ъ", "\xFA"); + addSubstitution("ы", "\xFB"); + addSubstitution("ь", "\xFC"); + addSubstitution("э", "\xFD"); + addSubstitution("ю", "\xFE"); + addSubstitution("я", "\xFF"); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/AppletFont.h b/src/graphics/niche/InkHUD/AppletFont.h new file mode 100644 index 000000000..89f901c94 --- /dev/null +++ b/src/graphics/niche/InkHUD/AppletFont.h @@ -0,0 +1,59 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Wrapper class for an AdafruitGFX font + Pre-calculates some font dimension info which InkHUD uses repeatedly + + Also contains an optional set of "substitutions". + These can be used to detect special UTF8 chars, and replace occurrences with a remapped char val to suit a custom font + These can also be used to swap UTF8 chars for a suitable ASCII substitution (e.g. German ö -> oe, etc) + +*/ + +#pragma once + +#include "configuration.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +// An AdafruitGFX font, bundled with precalculated dimensions which are used frequently by InkHUD +class AppletFont +{ + public: + AppletFont(); + AppletFont(const GFXfont &adafruitGFXFont); + uint8_t lineHeight(); + uint8_t heightAboveCursor(); + uint8_t heightBelowCursor(); + uint8_t widthBetweenWords(); + + void applySubstitutions(std::string *text); // Run all char-substitution operations, prior to printing + void addSubstitution(const char *from, const char *to); // Register a find-replace action, for remapping UTF8 chars + void addSubstitutionsWin1251(); // Cyrillic fonts: remap UTF8 values to their Win-1251 equivalent + // Todo: Polish font + + const GFXfont *gfxFont = NULL; // Default value: in-built AdafruitGFX font + + private: + uint8_t height = 8; // Default value: in-built AdafruitGFX font + uint8_t ascenderHeight = 0; // Default value: in-built AdafruitGFX font + uint8_t descenderHeight = 8; // Default value: in-built AdafruitGFX font + uint8_t spaceCharWidth = 8; // Default value: in-built AdafruitGFX font + + // One pair of find-replace values, for substituting or remapping UTF8 chars + struct Substitution { + const char *from; + const char *to; + }; + + // List of all character substitutions to run, prior to printing a string + std::vector substitutions; +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp new file mode 100644 index 000000000..21f404349 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -0,0 +1,429 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./MapApplet.h" + +using namespace NicheGraphics; + +void InkHUD::MapApplet::onRender() +{ + setFont(fontSmall); + + // Abort if no markers to render + if (!enoughMarkers()) { + printAt(X(0.5), Y(0.5) - (getFont().lineHeight() / 2), "Node positions", CENTER, MIDDLE); + printAt(X(0.5), Y(0.5) + (getFont().lineHeight() / 2), "will appear here", CENTER, MIDDLE); + return; + } + + // Find center of map + // - latitude and longitude + // - will be placed at X(0.5), Y(0.5) + getMapCenter(&latCenter, &lngCenter); + + // Calculate North+East distance of each node to map center + // - which nodes to use controlled by virtual shouldDrawNode method + calculateAllMarkers(); + + // Set the region shown on the map + // - default: fit all nodes, plus padding + // - maybe overriden by derived applet + getMapSize(&widthMeters, &heightMeters); + + // Set the metersToPx conversion value + calculateMapScale(); + + // Special marker for own node + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (ourNode && nodeDB->hasValidPosition(ourNode)) + drawLabeledMarker(ourNode); + + // Draw all markers + for (Marker m : markers) { + int16_t x = X(0.5) + (m.eastMeters * metersToPx); + int16_t y = Y(0.5) - (m.northMeters * metersToPx); + + // Cross Size + constexpr uint16_t csMin = 5; + constexpr uint16_t csMax = 12; + + // Too many hops away + if (m.hasHopsAway && m.hopsAway > config.lora.hop_limit) // Too many mops + printAt(x, y, "!", CENTER, MIDDLE); + else if (!m.hasHopsAway) // Unknown hops + drawCross(x, y, csMin); + else // The fewer hops, the larger the cross + drawCross(x, y, map(m.hopsAway, 0, config.lora.hop_limit, csMax, csMin)); + } +} + +// Find the center point, in the middle of all node positions +// Calculated values are written to the *lat and *long pointer args +// - Finds the "mean lat long" +// - Calculates furthest nodes from "mean lat long" +// - Place map center directly between these furthest nodes + +void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) +{ + // Find mean lat long coords + // ============================ + // - assigning X, Y and Z values to position on Earth's surface in 3D space, relative to center of planet + // - averages the x, y and z coords + // - uses tan to find angles for lat / long degrees + // - longitude: triangle formed by x and y (on plane of the equator) + // - latitude: triangle formed by z (north south), + // and the line along plane of equator which stetches from earth's axis to where point xyz intersects planet's surface + + // Working totals, averaged after nodeDB processed + uint32_t positionCount = 0; + float xAvg = 0; + float yAvg = 0; + float zAvg = 0; + + // For each node in db + for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + + // Skip if no position + if (!nodeDB->hasValidPosition(node)) + continue; + + // Skip if derived applet doesn't want to show this node on the map + if (!shouldDrawNode(node)) + continue; + + // Latitude and Longitude of node, in radians + float latRad = node->position.latitude_i * (1e-7) * DEG_TO_RAD; + float lngRad = node->position.longitude_i * (1e-7) * DEG_TO_RAD; + + // Convert to cartesian points, with center of earth at 0, 0, 0 + // Exact distance from center is irrelevant, as we're only interested in the vector + float x = cos(latRad) * cos(lngRad); + float y = cos(latRad) * sin(lngRad); + float z = sin(latRad); + + // To find mean values shortly + xAvg += x; + yAvg += y; + zAvg += z; + positionCount++; + } + + // All NodeDB processed, find mean values + xAvg /= positionCount; + yAvg /= positionCount; + zAvg /= positionCount; + + // Longitude from cartesian coords + // (Angle from 3D coords describing a point of globe's surface) + /* + UK + /-------\ + (Top View) /- -\ + /- (You) -\ + /- . -\ + /- . X -\ + Asia - ... - USA + \- Y -/ + \- -/ + \- -/ + \- -/ + \- -----/ + Pacific + + */ + + *lng = atan2(yAvg, xAvg) * RAD_TO_DEG; + + // Latitude from cartesian cooods + // (Angle from 3D coords describing a point on the globe's surface) + // As latitude increases, distance from the Earth's north-south axis out to our surface point decreases. + // Means we need to first find the hypotenuse which becomes base of our triangle in the second step + /* + UK North + /-------\ (Front View) /-------\ + (Top View) /- -\ /- -\ + /- (You) -\ /-(You) -\ + /- /. -\ /- . -\ + /- √X²+Y²/ . X -\ /- Z . -\ + Asia - /... - USA - ..... - + \- Y -/ \- √X²+Y² -/ + \- -/ \- -/ + \- -/ \- -/ + \- -/ \- -/ + \- -----/ \- -----/ + Pacific South + */ + + float hypotenuse = sqrt((xAvg * xAvg) + (yAvg * yAvg)); // Distance from globe's north-south axis to surface intersect + *lat = atan2(zAvg, hypotenuse) * RAD_TO_DEG; + + // ---------------------------------------------- + // This has given us the "mean position" + // This will be a position *somewhere* near the center of our nodes. + // What we actually want is to place our center so that our outermost nodes end up on the border of our map. + // The only real use of our "mean position" is to give us a reference frame: + // which direction is east, and which is west. + //------------------------------------------------ + + // Find furthest nodes from "mean lat long" + // ======================================== + + float northernmost = latCenter; + float southernmost = latCenter; + float easternmost = lngCenter; + float westernmost = lngCenter; + + for (uint8_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + + // Skip if no position + if (!nodeDB->hasValidPosition(node)) + continue; + + // Skip if derived applet doesn't want to show this node on the map + if (!shouldDrawNode(node)) + continue; + + // Check for a new top or bottom latitude + float lat = node->position.latitude_i * 1e-7; + northernmost = max(northernmost, lat); + southernmost = min(southernmost, lat); + + // Longitude is trickier + float lng = node->position.longitude_i * 1e-7; + float degEastward = fmod(((lng - lngCenter) + 360), 360); // Degrees travelled east from lngCenter to reach node + float degWestward = abs(fmod(((lng - lngCenter) - 360), 360)); // Degrees travelled west from lngCenter to reach node + if (degEastward < degWestward) + easternmost = max(easternmost, lngCenter + degEastward); + else + westernmost = min(westernmost, lngCenter - degWestward); + } + + // Todo: check for issues with map spans >180 deg. MQTT only.. + latCenter = (northernmost + southernmost) / 2; + lngCenter = (westernmost + easternmost) / 2; + + // In case our new center is west of -180, or east of +180, for some reason + lngCenter = fmod(lngCenter, 180); +} + +// Size of map in meters +// Grown to fit the nodes furthest from map center +// Overridable if derived applet wants a custom map size (fixed size?) +void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters) +{ + // Reset the value + *widthMeters = 0; + *heightMeters = 0; + + // Find the greatest distance horizontally and vertically from map center + for (Marker m : markers) { + *widthMeters = max(*widthMeters, (uint32_t)abs(m.eastMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)abs(m.northMeters) * 2); + } + + // Add padding + *widthMeters *= 1.1; + *heightMeters *= 1.1; +} + +// Convert and store info we need for drawing a marker +// Lat / long to "meters relative to map center", for position on screen +// Info about hopsAway, for marker size +InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway) +{ + assert(lat != 0 || lng != 0); // Not null island. Applets should check this before calling. + + // Bearing and distance from map center to node + float distanceFromCenter = GeoCoord::latLongToMeter(latCenter, lngCenter, lat, lng); + float bearingFromCenter = GeoCoord::bearing(latCenter, lngCenter, lat, lng); // in radians + + // Split into meters north and meters east components (signed) + // - signedness of cos / sin automatically sets negative if south or west + float northMeters = cos(bearingFromCenter) * distanceFromCenter; + float eastMeters = sin(bearingFromCenter) * distanceFromCenter; + + // Store this as a new marker + Marker m; + m.eastMeters = eastMeters; + m.northMeters = northMeters; + m.hasHopsAway = hasHopsAway; + m.hopsAway = hopsAway; + return m; +} + +// Draw a marker on the map for a node, with a shortname label, and backing box +void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node) +{ + // Find x and y position based on node's position in nodeDB + assert(nodeDB->hasValidPosition(node)); + Marker m = calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style + node->position.longitude_i * 1e-7, // Long, convered from Meshtastic's internal int32 style + node->has_hops_away, // Is the hopsAway number valid + node->hops_away // Hops away + ); + + // Convert to pixel coords + int16_t markerX = X(0.5) + (m.eastMeters * metersToPx); + int16_t markerY = Y(0.5) - (m.northMeters * metersToPx); + + constexpr uint16_t paddingH = 2; + constexpr uint16_t paddingW = 4; + uint16_t paddingInnerW = 2; // Zero'd out if no text + constexpr uint16_t markerSizeMax = 12; // Size of cross (if marker uses a cross) + constexpr uint16_t markerSizeMin = 5; + + int16_t textX; + int16_t textY; + uint16_t textW; + uint16_t textH; + int16_t labelX; + int16_t labelY; + uint16_t labelW; + uint16_t labelH; + uint8_t markerSize; + + bool tooManyHops = node->hops_away > config.lora.hop_limit; + bool isOurNode = node->num == nodeDB->getNodeNum(); + bool unknownHops = !node->has_hops_away && !isOurNode; + + // We will draw a left or right hand variant, to place text towards screen center + // Hopfully avoid text spilling off screen + // Most values are the same, regardless of left-right handedness + + // Pick emblem style + if (tooManyHops) + markerSize = getTextWidth("!"); + else if (unknownHops) + markerSize = markerSizeMin; + else + markerSize = map(node->hops_away, 0, config.lora.hop_limit, markerSizeMax, markerSizeMin); + + // Common dimensions (left or right variant) + textW = getTextWidth(node->user.short_name); + if (textW == 0) + paddingInnerW = 0; // If no text, no padding for text + textH = fontSmall.lineHeight(); + labelH = paddingH + max((int16_t)(textH), (int16_t)markerSize) + paddingH; + labelY = markerY - (labelH / 2); + textY = markerY; + labelW = paddingW + markerSize + paddingInnerW + textW + paddingW; // Width is same whether right or left hand variant + + // Left-side variant + if (markerX < width() / 2) { + labelX = markerX - (markerSize / 2) - paddingW; + textX = labelX + paddingW + markerSize + paddingInnerW; + } + + // Right-side variant + else { + labelX = markerX - (markerSize / 2) - paddingInnerW - textW - paddingW; + textX = labelX + paddingW; + } + + // Backing box + fillRect(labelX, labelY, labelW, labelH, WHITE); + drawRect(labelX, labelY, labelW, labelH, BLACK); + + // Short name + printAt(textX, textY, node->user.short_name, LEFT, MIDDLE); + + // If the label is for our own node, + // fade it by overdrawing partially with white + if (node == nodeDB->getMeshNode(nodeDB->getNodeNum())) + hatchRegion(labelX, labelY, labelW, labelH, 2, WHITE); + + // Draw the marker emblem + // - after the fading, because hatching (own node) can align with cross and make it look weird + if (tooManyHops) + printAt(markerX, markerY, "!", CENTER, MIDDLE); + else + drawCross(markerX, markerY, markerSize); // The fewer the hops, the larger the marker. Also handles unknownHops +} + +// Check if we actually have enough nodes which would be shown on the map +// Need at least two, to draw a sensible map +bool InkHUD::MapApplet::enoughMarkers() +{ + uint8_t count = 0; + for (uint8_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + + // Count nodes + if (nodeDB->hasValidPosition(node) && shouldDrawNode(node)) + count++; + + // We need to find two + if (count == 2) + return true; // Two nodes is enough for a sensible map + } + + return false; // No nodes would be drawn (or just the one, uselessly at 0,0) +} + +// Calculate how far north and east of map center each node is +// Derived applets can control which nodes to calculate (and later, draw) by overriding MapApplet::shouldDrawNode +void InkHUD::MapApplet::calculateAllMarkers() +{ + // Clear old markers + markers.clear(); + + // For each node in db + for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + + // Skip if no position + if (!nodeDB->hasValidPosition(node)) + continue; + + // Skip if derived applet doesn't want to show this node on the map + if (!shouldDrawNode(node)) + continue; + + // Skip if our own node + // - special handling in render() + if (node->num == nodeDB->getNodeNum()) + continue; + + // Calculate marker and store it + markers.push_back( + calculateMarker(node->position.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style + node->position.longitude_i * 1e-7, // Long, convered from Meshtastic's internal int32 style + node->has_hops_away, // Is the hopsAway number valid + node->hops_away // Hops away + )); + } +} + +// Determine the conversion factor between metres, and pixels on screen +// May be overriden by derived applet, if custom scale required (fixed map size?) +void InkHUD::MapApplet::calculateMapScale() +{ + // Aspect ratio of map and screen + // - larger = wide, smaller = tall + // - used to set scale, so that widest map dimension fits in applet + float mapAspectRatio = (float)widthMeters / heightMeters; + float appletAspectRatio = (float)width() / height(); + + // "Shrink to fit" + // Scale the map so that the largest dimension is fully displayed + // Because aspect ratio will be maintained, the other dimension will appear "padded" + if (mapAspectRatio > appletAspectRatio) + metersToPx = (float)width() / widthMeters; // Too wide for applet. Constrain to fit width. + else + metersToPx = (float)height() / heightMeters; // Too tall for applet. Constrain to fit height. +} + +// Draw an x, centered on a specific point +// Most markers will draw with this method +void InkHUD::MapApplet::drawCross(int16_t x, int16_t y, uint8_t size) +{ + int16_t x0 = x - (size / 2); + int16_t y0 = y - (size / 2); + int16_t x1 = x0 + size - 1; + int16_t y1 = y0 + size - 1; + drawLine(x0, y0, x1, y1, BLACK); + drawLine(x0, y1, x1, y0, BLACK); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h new file mode 100644 index 000000000..fd5245631 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h @@ -0,0 +1,66 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Base class for Applets which show nodes on a map + +Plots position of for a selection of nodes, with north facing up. +Size of cross represents hops away. +Our own node is identified with a faded label. + +The base applet doesn't handle any events; this is left to the derived applets. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +#include "MeshModule.h" +#include "gps/GeoCoord.h" + +namespace NicheGraphics::InkHUD +{ + +class MapApplet : public Applet +{ + public: + void onRender() override; + + protected: + virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes + virtual void getMapCenter(float *lat, float *lng); + virtual void getMapSize(uint32_t *widthMeters, uint32_t *heightMeters); + + bool enoughMarkers(); // Anything to draw? + void drawLabeledMarker(meshtastic_NodeInfoLite *node); // Highlight a specific marker + + private: + // Position of markers to be drawn, relative to map center + // HopsAway info used to determine marker size + struct Marker { + float eastMeters = 0; // Meters east of mapCenter. Negative if west. + float northMeters = 0; // Meters north of mapCenter. Negative if south. + bool hasHopsAway = false; + uint8_t hopsAway = 0; + }; + + Marker calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway); + void calculateAllMarkers(); + void calculateMapScale(); // Conversion factor for meters to pixels + void drawCross(int16_t x, int16_t y, uint8_t size); // Draw the X used for most markers + + float metersToPx = 0; // Conversion factor for meters to pixels + float latCenter = 0; // Map center: latitude + float lngCenter = 0; // Map center: longitude + + std::list markers; + uint32_t widthMeters = 0; // Map width: meters + uint32_t heightMeters = 0; // Map height: meters +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp new file mode 100644 index 000000000..1e3cd3137 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp @@ -0,0 +1,283 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "rtc.h" + +#include "GeoCoord.h" +#include "NodeDB.h" + +#include "./NodeListApplet.h" + +using namespace NicheGraphics; + +InkHUD::NodeListApplet::NodeListApplet(const char *name) : MeshModule(name) +{ + // We only need to be promiscuous in order to hear NodeInfo, apparently. See NodeInfoModule + // For all other packets, we manually reimplement isPromiscuous=false in wantPacket + MeshModule::isPromiscuous = true; +} + +// Do we want to process this packet with handleReceived()? +bool InkHUD::NodeListApplet::wantPacket(const meshtastic_MeshPacket *p) +{ + // Only interested if: + return isActive() // Applet is active + && !isFromUs(p) // Packet is incoming (not outgoing) + && (isToUs(p) || isBroadcast(p->to) || // Either: intended for us, + p->decoded.portnum == meshtastic_PortNum_NODEINFO_APP); // or nodeinfo + + // Note: special handling of NodeInfo is to match NodeInfoModule + // To match the behavior seen in the client apps: + // - NodeInfoModule's ProtoBufModule base is "promiscuous" + // - All other activity is *not* promiscuous + // To achieve this, our MeshModule *is* promiscious, and we're manually reimplementing non-promiscuous behavior here, + // to match the code in MeshModule::callModules +} + +// MeshModule packets arrive here +// Extract the info and pass it to the derived applet +// Derived applet will store the CardInfo and perform any required sorting of the CardInfo collection +// Derived applet might also need to keep other tallies (active nodes count?) +ProcessMessage InkHUD::NodeListApplet::handleReceived(const meshtastic_MeshPacket &mp) +{ + // Abort if applet fully deactivated + // Already handled by wantPacket in this case, but good practice for all applets, as some *do* require this early return + if (!isActive()) + return ProcessMessage::CONTINUE; + + // Assemble info: from this event + CardInfo c; + c.nodeNum = mp.from; + c.signal = getSignalStrength(mp.rx_snr, mp.rx_rssi); + + // Assemble info: from nodeDB (needed to detect changes) + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(c.nodeNum); + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (node) { + if (node->has_hops_away) + c.hopsAway = node->hops_away; + + if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) { + // Get lat and long as float + // Meshtastic stores these as integers internally + float ourLat = ourNode->position.latitude_i * 1e-7; + float ourLong = ourNode->position.longitude_i * 1e-7; + float theirLat = node->position.latitude_i * 1e-7; + float theirLong = node->position.longitude_i * 1e-7; + + c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + } + } + + // Pass to the derived applet + // Derived applet is responsible for requesting update, if justified + // That request will eventually trigger our class' onRender method + handleParsed(c); + + return ProcessMessage::CONTINUE; // Let others look at this message also if they want +} + +// Maximum number of cards we may ever need to render, in our tallest layout config +// May be slightly in excess of the true value: header not accounted for +uint8_t InkHUD::NodeListApplet::maxCards() +{ + // Cache result. Shouldn't change during execution + static uint8_t cards = 0; + + if (!cards) { + const uint16_t height = Tile::maxDisplayDimension(); + + // Use a loop instead of arithmetic, because it's easier for my brain to follow + // Add cards one by one, until the latest card (without margin) extends below screen + + uint16_t y = cardH; // First card: no margin above + cards = 1; + + while (y < height) { + y += cardMarginH; + y += cardH; + cards++; + } + } + + return cards; +} + +// Draw using info which derived applet placed into NodeListApplet::cards for us +void InkHUD::NodeListApplet::onRender() +{ + + // ================================ + // Draw the standard applet header + // ================================ + + drawHeader(getHeaderText()); // Ask derived applet for the title + + // Dimensions of the header + int16_t headerDivY = getHeaderHeight() - 1; + constexpr uint16_t padDivH = 2; + + // ======================== + // Draw the main node list + // ======================== + + // const uint8_t cardMarginH = fontSmall.lineHeight() / 2; // Gap between cards + // const uint16_t cardH = fontLarge.lineHeight() + fontSmall.lineHeight() + cardMarginH; + + // Imaginary vertical line dividing left-side and right-side info + // Long-name will crop here + const uint16_t dividerX = (width() - 1) - getTextWidth("X Hops"); + + // Y value (top) of the current card. Increases as we draw. + uint16_t cardTopY = headerDivY + padDivH; + + // -- Each node in list -- + for (auto card = cards.begin(); card != cards.end(); ++card) { + + // Gather info + // ======================================== + NodeNum &nodeNum = card->nodeNum; + SignalStrength &signal = card->signal; + std::string longName; // handled below + std::string shortName; // handled below + std::string distance; // handled below; + uint8_t &hopsAway = card->hopsAway; + + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum); + + // -- Shortname -- + // use "?" if unknown + if (node && node->has_user) + shortName = node->user.short_name; + else + shortName = "?"; + + // -- Longname -- + // use node id if unknown + if (node && node->has_user) + longName = node->user.long_name; // Found in nodeDB + else { + // Not found in nodeDB, show a hex nodeid instead + longName = hexifyNodeNum(nodeNum); + } + + // -- Distance -- + if (card->distanceMeters != CardInfo::DISTANCE_UNKNOWN) + distance = localizeDistance(card->distanceMeters); + + // Draw the info + // ==================================== + + // Define two lines of text for the card + // We will center our text on these lines + uint16_t lineAY = cardTopY + (fontLarge.lineHeight() / 2); + uint16_t lineBY = cardTopY + fontLarge.lineHeight() + (fontSmall.lineHeight() / 2); + + // Print the short name + setFont(fontLarge); + printAt(0, lineAY, shortName, LEFT, MIDDLE); + + // Print the distance + setFont(fontSmall); + printAt(width() - 1, lineBY, distance, RIGHT, MIDDLE); + + // If we have a direct connection to the node, draw the signal indicator + if (hopsAway == 0 && signal != SIGNAL_UNKNOWN) { + uint16_t signalW = getTextWidth("Xkm"); // Indicator should be similar width to distance label + uint16_t signalH = fontLarge.lineHeight() * 0.75; + int16_t signalY = lineAY + (fontLarge.lineHeight() / 2) - (fontLarge.lineHeight() * 0.75); + int16_t signalX = width() - signalW; + drawSignalIndicator(signalX, signalY, signalW, signalH, signal); + } + // Otherwise, print "hops away" info, if available + else if (hopsAway != CardInfo::HOPS_UNKNOWN) { + std::string hopString = to_string(node->hops_away); + hopString += " Hop"; + if (node->hops_away != 1) + hopString += "s"; // Append s for "Hops", rather than "Hop" + + printAt(width() - 1, lineAY, hopString, RIGHT, MIDDLE); + } + + // Print the long name, cropping to prevent overflow onto the right-side info + setCrop(0, 0, dividerX - 1, height()); + printAt(0, lineBY, longName, LEFT, MIDDLE); + + // GFX effect: "hatch" the right edge of longName area + // If a longName has been cropped, it will appear to fade out, + // creating a soft barrier with the right-side info + const int16_t hatchLeft = dividerX - 1 - (fontSmall.lineHeight()); + const int16_t hatchWidth = fontSmall.lineHeight(); + hatchRegion(hatchLeft, cardTopY, hatchWidth, cardH, 2, WHITE); + + // Prepare to draw the next card + resetCrop(); + cardTopY += cardH; + + // Once we've run out of screen, stop drawing cards + // Depending on tiles / rotation, this may be before we hit maxCards + if (cardTopY > height()) { + break; + } + } +} + +// Draw element: a "mobile phone" style signal indicator +// We will calculate values as floats, then "rasterize" at the last moment, relative to x and w, etc +// This prevents issues with premature rounding when rendering tiny elements +void InkHUD::NodeListApplet::drawSignalIndicator(int16_t x, int16_t y, uint16_t w, uint16_t h, SignalStrength strength) +{ + + /* + +-------------------------------------------+ + | | + | | + | barHeightRelative=1.0 + | +--+ ^ | + | gutterW +--+ | | | | + | <--> +--+ | | | | | | + | +--+ | | | | | | | | + | | | | | | | | | | | + | <-> +--+ +--+ +--+ +--+ v | + | paddingW ^ | + | paddingH | | + | v | + +-------------------------------------------+ + */ + + constexpr float paddingW = 0.1; // Either side + constexpr float paddingH = 0.1; // Above and below + constexpr float gutterX = 0.1; // Between bars + + constexpr float barHRel[] = {0.3, 0.5, 0.7, 1.0}; // Heights of the signal bars, relative to the talleest + constexpr uint8_t barCount = 4; // How many bars we draw. Reference only: changing value won't change the count. + + // Dynamically calculate the width of the bars, and height of the rightmost, relative to other dimensions + float barW = (1.0 - (paddingW + ((barCount - 1) * gutterX) + paddingW)) / barCount; + float barHMax = 1.0 - (paddingH + paddingH); + + // Draw signal bar rectangles, then placeholder lines once strength reached + for (uint8_t i = 0; i < barCount; i++) { + // Co-ords for this specific bar + float barH = barHMax * barHRel[i]; + float barX = paddingW + (i * (gutterX + barW)); + float barY = paddingH + (barHMax - barH); + + // Rasterize to px coords at the last moment + int16_t rX = (x + (w * barX)) + 0.5; + int16_t rY = (y + (h * barY)) + 0.5; + uint16_t rW = (w * barW) + 0.5; + uint16_t rH = (h * barH) + 0.5; + + // Draw signal bars, until we are displaying the correct "signal strength", then just draw placeholder lines + if (i <= strength) + drawRect(rX, rY, rW, rH, BLACK); + else { + // Just draw a placeholder line + float lineY = barY + barH; + uint16_t rLineY = (y + (h * lineY)) + 0.5; // Rasterize + drawLine(rX, rLineY, rX + rW - 1, rLineY, BLACK); + } + } +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.h new file mode 100644 index 000000000..670dd9e9a --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.h @@ -0,0 +1,71 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Base class for Applets which display a list of nodes +Used by the "Recents" and "Heard" applets. Possibly more in future? + + +-------------------------------+ + | | | + | SHRT . | | | + | Long name 50km | + | | + | ABCD 2 Hops | + | abcdedfghijk 30km | + | | + +-------------------------------+ + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class NodeListApplet : public Applet, public MeshModule +{ + protected: + // Info used to draw one card to the node list + struct CardInfo { + static constexpr uint8_t HOPS_UNKNOWN = -1; + static constexpr uint32_t DISTANCE_UNKNOWN = -1; + + NodeNum nodeNum = 0; + SignalStrength signal = SignalStrength::SIGNAL_UNKNOWN; + uint32_t distanceMeters = DISTANCE_UNKNOWN; + uint8_t hopsAway = HOPS_UNKNOWN; // Unknown + }; + + public: + NodeListApplet(const char *name); + void onRender() override; + + // MeshModule overrides + virtual bool wantPacket(const meshtastic_MeshPacket *p) override; + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + protected: + virtual void handleParsed(CardInfo c) = 0; // Pass extracted info from a new packet to derived class, for sorting and storage + virtual std::string getHeaderText() = 0; // Title for the applet's header. Todo: get this info another way? + + uint8_t maxCards(); // Calculate the maximum number of cards an applet could ever display + + std::deque cards; // Derived applet places cards here, for this base applet to render + + private: + // UI element: a "mobile phone" style signal indicator + void drawSignalIndicator(int16_t x, int16_t y, uint16_t w, uint16_t h, SignalStrength signal); + + // Dimensions for drawing + // Used for render, and also for maxCards calc + const uint8_t cardMarginH = fontSmall.lineHeight() / 2; // Gap between cards + const uint16_t cardH = fontLarge.lineHeight() + fontSmall.lineHeight() + cardMarginH; // Height of card +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.cpp b/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.cpp new file mode 100644 index 000000000..17458ab96 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.cpp @@ -0,0 +1,14 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./BasicExampleApplet.h" + +using namespace NicheGraphics; + +// All drawing happens here +// Our basic example doesn't do anything useful. It just passively prints some text. +void InkHUD::BasicExampleApplet::onRender() +{ + print("Hello, World!"); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h b/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h new file mode 100644 index 000000000..aed63cdc8 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h @@ -0,0 +1,36 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +A bare-minimum example of an InkHUD applet. +Only prints Hello World. + +In variants//nicheGraphics.h: + + - include this .h file + - add the following line of code: + windowManager->addApplet("Basic", new InkHUD::BasicExampleApplet); + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class BasicExampleApplet : public Applet +{ + public: + // You must have an onRender() method + // All drawing happens here + + void onRender() override; +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.cpp b/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.cpp new file mode 100644 index 000000000..e0b2a4238 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.cpp @@ -0,0 +1,54 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./NewMsgExampleApplet.h" + +using namespace NicheGraphics; + +// We configured MeshModule API to call this method when we receive a new text message +ProcessMessage InkHUD::NewMsgExampleApplet::handleReceived(const meshtastic_MeshPacket &mp) +{ + + // Abort if applet fully deactivated + if (!isActive()) + return ProcessMessage::CONTINUE; + + // Check that this is an incoming message + // Outgoing messages (sent by us) will also call handleReceived + + if (!isFromUs(&mp)) { + // Store the sender's nodenum + // We need to keep this information, so we can re-use it anytime render() is called + haveMessage = true; + fromWho = mp.from; + + // Tell InkHUD that we have something new to show on the screen + requestUpdate(); + } + + // Tell MeshModule API to continue informing other firmware components about this message + // We're not the only component which is interested in new text messages + return ProcessMessage::CONTINUE; +} + +// All drawing happens here +// We can trigger a render by calling requestUpdate() +// Render might be called by some external source +// We should always be ready to draw +void InkHUD::NewMsgExampleApplet::onRender() +{ + setFont(fontSmall); + + printAt(0, 0, "Example: NewMsg", LEFT, TOP); // Print top-left corner of text at (0,0) + + int16_t centerX = X(0.5); // Same as width() / 2 + int16_t centerY = Y(0.5); // Same as height() / 2 + + if (haveMessage) { + printAt(centerX, centerY, "New Message", CENTER, BOTTOM); + printAt(centerX, centerY, "From: " + hexifyNodeNum(fromWho), CENTER, TOP); + } else { + printAt(centerX, centerY, "No Message", CENTER, MIDDLE); // Place center of string at (centerX, centerY) + } +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h b/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h new file mode 100644 index 000000000..edfb211d7 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h @@ -0,0 +1,61 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +An example of an InkHUD applet. +Tells us when a new text message arrives. + +This applet makes use of the MeshModule API to detect new messages, +which is a general part of the Meshtastic firmware, and not part of InkHUD. + +In variants//nicheGraphics.h: + + - include this .h file + - add the following line of code: + windowManager->addApplet("New Msg", new InkHUD::NewMsgExampleApplet); + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +#include "mesh/SinglePortModule.h" + +namespace NicheGraphics::InkHUD +{ + +class NewMsgExampleApplet : public Applet, public SinglePortModule +{ + public: + // The MeshModule API requires us to have a constructor, to specify that we're interested in Text Messages. + NewMsgExampleApplet() : SinglePortModule("NewMsgExampleApplet", meshtastic_PortNum_TEXT_MESSAGE_APP) {} + + // All drawing happens here + void onRender() override; + + // Your applet might also want to use some of these + // Useful for setting up or tidying up + + /* + void onActivate(); // When started + void onDeactivate(); // When stopped + void onForeground(); // When shown by short-press + void onBackground(); // When hidden by short-press + */ + + private: + // Called when we receive new text messages + // Part of the MeshModule API + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + // Store info from handleReceived + bool haveMessage = false; + NodeNum fromWho; +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.cpp new file mode 100644 index 000000000..e4432a7c2 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.cpp @@ -0,0 +1,107 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./BatteryIconApplet.h" + +using namespace NicheGraphics; + +void InkHUD::BatteryIconApplet::onActivate() +{ + // Show at boot, if user has previously enabled the feature + if (settings.optionalFeatures.batteryIcon) + bringToForeground(); + + // Register to our have BatteryIconApplet::onPowerStatusUpdate method called when new power info is available + // This happens whether or not the battery icon feature is enabled + powerStatusObserver.observe(&powerStatus->onNewStatus); +} + +void InkHUD::BatteryIconApplet::onDeactivate() +{ + // Stop having onPowerStatusUpdate called + powerStatusObserver.unobserve(&powerStatus->onNewStatus); +} + +// We handle power status' even when the feature is disabled, +// so that we have up to date data ready if the feature is enabled later. +// Otherwise could be 30s before new status update, with weird battery value displayed +int InkHUD::BatteryIconApplet::onPowerStatusUpdate(const meshtastic::Status *status) +{ + // System applets are always active + assert(isActive()); + + // This method should only receive power statuses + // If we get a different type of status, something has gone weird elsewhere + assert(status->getStatusType() == STATUS_TYPE_POWER); + + meshtastic::PowerStatus *powerStatus = (meshtastic::PowerStatus *)status; + + // Get the new state of charge %, and round to the nearest 10% + uint8_t newSocRounded = ((powerStatus->getBatteryChargePercent() + 5) / 10) * 10; + + // If rounded value has changed, trigger a display update + // It's okay to requestUpdate before we store the new value, as the update won't run until next loop() + // Don't trigger an update if the feature is disabled + if (this->socRounded != newSocRounded && settings.optionalFeatures.batteryIcon) + requestUpdate(); + + // Store the new value + this->socRounded = newSocRounded; + + return 0; // Tell Observable to continue informing other observers +} + +void InkHUD::BatteryIconApplet::onRender() +{ + // Fill entire tile + // - size of icon controlled by size of tile + int16_t l = 0; + int16_t t = 0; + uint16_t w = width(); + int16_t h = height(); + + // Clear the region beneath the tile + // Most applets are drawing onto an empty frame buffer and don't need to do this + // We do need to do this with the battery though, as it is an "overlay" + fillRect(l, t, w, h, WHITE); + + // Vertical centerline + const int16_t m = t + (h / 2); + + // ===================== + // Draw battery outline + // ===================== + + // Positive terminal "bump" + const int16_t &bumpL = l; + const uint16_t bumpH = h / 2; + const int16_t bumpT = m - (bumpH / 2); + constexpr uint16_t bumpW = 2; + fillRect(bumpL, bumpT, bumpW, bumpH, BLACK); + + // Main body of battery + const int16_t bodyL = bumpL + bumpW; + const int16_t &bodyT = t; + const int16_t &bodyH = h; + const int16_t bodyW = w - bumpW; + drawRect(bodyL, bodyT, bodyW, bodyH, BLACK); + + // Erase join between bump and body + drawLine(bodyL, bumpT, bodyL, bumpT + bumpH - 1, WHITE); + + // =================== + // Draw battery level + // =================== + + constexpr int16_t slicePad = 2; + const int16_t sliceL = bodyL + slicePad; + const int16_t sliceT = bodyT + slicePad; + const uint16_t sliceH = bodyH - (slicePad * 2); + uint16_t sliceW = bodyW - (slicePad * 2); + + sliceW = (sliceW * socRounded) / 100; // Apply percentage + + hatchRegion(sliceL, sliceT, sliceW, sliceH, 2, BLACK); + drawRect(sliceL, sliceT, sliceW, sliceH, BLACK); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.h b/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.h new file mode 100644 index 000000000..765ca073f --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/BatteryIcon/BatteryIconApplet.h @@ -0,0 +1,41 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +This applet floats top-left, giving a graphical representation of battery remaining +It should be optional, enabled by the on-screen menu + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +#include "PowerStatus.h" + +namespace NicheGraphics::InkHUD +{ + +class BatteryIconApplet : public Applet +{ + public: + void onRender() override; + + void onActivate() override; + void onDeactivate() override; + + int onPowerStatusUpdate(const meshtastic::Status *status); // Called when new info about battery is available + + protected: + // Get informed when new information about the battery is available (via onPowerStatusUpdate method) + CallbackObserver powerStatusObserver = + CallbackObserver(this, &BatteryIconApplet::onPowerStatusUpdate); + + uint8_t socRounded = 0; // Battery state of charge, rounded to nearest 10% +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp new file mode 100644 index 000000000..cc24417ab --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp @@ -0,0 +1,108 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./LogoApplet.h" + +using namespace NicheGraphics; + +InkHUD::LogoApplet::LogoApplet() : concurrency::OSThread("LogoApplet") +{ + // Don't autostart the runOnce() timer + OSThread::disable(); + + // Grab the WindowManager singleton, for convenience + windowManager = WindowManager::getInstance(); +} + +void InkHUD::LogoApplet::onRender() +{ + // Size of the region which the logo should "scale to fit" + uint16_t logoWLimit = X(0.8); + uint16_t logoHLimit = Y(0.5); + + // Get the max width and height we can manage within the region, while still maintaining aspect ratio + uint16_t logoW = getLogoWidth(logoWLimit, logoHLimit); + uint16_t logoH = getLogoHeight(logoWLimit, logoHLimit); + + // Where to place the center of the logo + int16_t logoCX = X(0.5); + int16_t logoCY = Y(0.5 - 0.05); + + drawLogo(logoCX, logoCY, logoW, logoH); + + if (!textLeft.empty()) { + setFont(fontSmall); + printAt(0, 0, textLeft, LEFT, TOP); + } + + if (!textRight.empty()) { + setFont(fontSmall); + printAt(X(1), 0, textRight, RIGHT, TOP); + } + + if (!textTitle.empty()) { + int16_t logoB = logoCY + (logoH / 2); // Bottom of the logo + setFont(fontTitle); + printAt(X(0.5), logoB + Y(0.1), textTitle, CENTER, TOP); + } +} + +void InkHUD::LogoApplet::onForeground() +{ + // If another applet has locked the display, ask it to exit + Applet *other = windowManager->whoLocked(); + if (other != nullptr) + other->sendToBackground(); + + windowManager->claimFullscreen(this); // Take ownership of fullscreen tile + windowManager->lock(this); // Prevent other applets from requesting updates +} + +void InkHUD::LogoApplet::onBackground() +{ + OSThread::disable(); // Disable auto-dismiss timer, in case applet was dismissed early (sendToBackground from outside class) + + windowManager->releaseFullscreen(); // Relinquish ownership of fullscreen tile + windowManager->unlock(this); // Allow normal user applet update requests to resume + + // Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background + // Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case + windowManager->forceUpdate(EInk::UpdateTypes::FULL); +} + +int32_t InkHUD::LogoApplet::runOnce() +{ + LOG_DEBUG("Sent to background by timer"); + sendToBackground(); + return OSThread::disable(); +} + +// Begin displaying the screen which is shown at startup +// Suggest EInk::await after calling this method +void InkHUD::LogoApplet::showBootScreen() +{ + OSThread::setIntervalFromNow(8 * 1000UL); + OSThread::enabled = true; + + textLeft = ""; + textRight = ""; + textTitle = xstr(APP_VERSION_SHORT); + fontTitle = fontSmall; + + bringToForeground(); + requestUpdate(Drivers::EInk::UpdateTypes::FULL); // Already requested, just upgrading to FULL +} + +// Begin displaying the screen which is shown at shutdown +// Needs EInk::await after calling this method, to ensure display updates before shutdown +void InkHUD::LogoApplet::showShutdownScreen() +{ + textLeft = ""; + textRight = ""; + textTitle = owner.short_name; + fontTitle = fontLarge; + + bringToForeground(); + requestUpdate(Drivers::EInk::UpdateTypes::FULL); // Already requested, just upgrading to FULL +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.h b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.h new file mode 100644 index 000000000..aa1bf8b2c --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.h @@ -0,0 +1,47 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Shows the Meshtastic logo fullscreen, with accompanying text + Used for boot and shutdown + +*/ + +#pragma once + +#include "configuration.h" + +#include "concurrency/OSThread.h" +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class LogoApplet : public Applet, public concurrency::OSThread +{ + public: + LogoApplet(); + void onRender() override; + void onForeground() override; + void onBackground() override; + + // Note: interacting directly with an applet like this is non-standard + // Only permitted because this is a "system applet", which has special behavior and interacts directly with WindowManager + + void showBootScreen(); + void showShutdownScreen(); + + protected: + int32_t runOnce() override; + + std::string textLeft; + std::string textRight; + std::string textTitle; + AppletFont fontTitle; + + WindowManager *windowManager = nullptr; // For convenience +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h new file mode 100644 index 000000000..6950bb110 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -0,0 +1,38 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Set of end-point actions for the Menu Applet + +Added as menu entries in MenuApplet::showPage +Behaviors assigned in MenuApplet::execute + +*/ + +#pragma once + +#include "configuration.h" + +namespace NicheGraphics::InkHUD +{ + +enum MenuAction { + NO_ACTION, + SEND_NODEINFO, + SEND_POSITION, + SHUTDOWN, + NEXT_TILE, + TOGGLE_APPLET, + ACTIVATE_APPLETS, // Todo: remove? Possible redundant, handled by TOGGLE_APPLET? + TOGGLE_AUTOSHOW_APPLET, + SET_RECENTS, + ROTATE, + LAYOUT, + TOGGLE_BATTERY_ICON, + TOGGLE_NOTIFICATIONS, + TOGGLE_BACKLIGHT, +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp new file mode 100644 index 000000000..b937ef1f4 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -0,0 +1,612 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./MenuApplet.h" + +#include "PowerStatus.h" +#include "rtc.h" + +using namespace NicheGraphics; + +static constexpr uint8_t MENU_TIMEOUT_SEC = 60; // How many seconds before menu auto-closes + +// Options for the "Recents" menu +// These are offered to users as possible values for settings.recentlyActiveSeconds +static constexpr uint8_t RECENTS_OPTIONS_MINUTES[] = {2, 5, 10, 30, 60, 120}; + +InkHUD::MenuApplet::MenuApplet() : concurrency::OSThread("MenuApplet") +{ + // No timer tasks at boot + OSThread::disable(); +} + +void InkHUD::MenuApplet::onActivate() +{ + // Grab pointers to some singleton components which the menu interacts with + // We could do this every time we needed them, in place, + // but this just makes the code tidier + + this->windowManager = WindowManager::getInstance(); + + // Note: don't get instance if we're not actually using the backlight, + // or else you will unintentionally instantiate it + if (settings.optionalMenuItems.backlight) { + backlight = Drivers::LatchingBacklight::getInstance(); + } +} + +void InkHUD::MenuApplet::onForeground() +{ + // We do need this before we render, but we can optimize by just calculating it once now + systemInfoPanelHeight = getSystemInfoPanelHeight(); + + // Display initial menu page + showPage(MenuPage::ROOT); + + // If device has a backlight which isn't controlled by aux button: + // backlight on always when menu opens. + // Courtesy to T-Echo users who removed the capacitive touch button + if (settings.optionalMenuItems.backlight) { + assert(backlight); + if (!backlight->isOn()) + backlight->peek(); + } + + // Prevent user applets requested update while menu is open + windowManager->lock(this); + + // Begin the auto-close timeout + OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + OSThread::enabled = true; + + // Upgrade the refresh to FAST, for guaranteed responsiveness + windowManager->forceUpdate(EInk::UpdateTypes::FAST); +} + +void InkHUD::MenuApplet::onBackground() +{ + // If device has a backlight which isn't controlled by aux button: + // Item in options submenu allows keeping backlight on after menu is closed + // If this item is deselected we will turn backlight off again, now that menu is closing + if (settings.optionalMenuItems.backlight) { + assert(backlight); + if (!backlight->isLatched()) + backlight->off(); + } + + // Stop the auto-timeout + OSThread::disable(); + + // Resume normal rendering and button behavior of user applets + windowManager->unlock(this); + + // Restore the user applet whose tile we borrowed + if (borrowedTileOwner) + borrowedTileOwner->bringToForeground(); + Tile *t = getTile(); + t->assignApplet(borrowedTileOwner); // Break our link with the tile, (and relink it with real owner, if it had one) + borrowedTileOwner = nullptr; + + // Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background + // We're only updating here to ugrade from UNSPECIFIED to FAST, to ensure responsiveness when exiting menu + windowManager->forceUpdate(EInk::UpdateTypes::FAST); +} + +// Open the menu +// Parameter specifies which user-tile the menu will use +// The user applet originally on this tile will be restored when the menu closes +void InkHUD::MenuApplet::show(Tile *t) +{ + // Remember who *really* owns this tile + borrowedTileOwner = t->getAssignedApplet(); + + // Hide the owner, if it is a valid applet + if (borrowedTileOwner) + borrowedTileOwner->sendToBackground(); + + // Break the owner's link with tile + // Relink it to menu applet + t->assignApplet(this); + + // Show menu + bringToForeground(); +} + +// Auto-exit the menu applet after a period of inactivity +// The values shown on the root menu are only a snapshot: they are not re-rendered while the menu remains open. +// By exiting the menu, we prevent users mistakenly believing that the data will update. +int32_t InkHUD::MenuApplet::runOnce() +{ + // runOnce's interval is pushed back when a button is pressed + // If we do actually run, it means no button input occurred within MENU_TIMEOUT_SEC, + // so we close the menu. + showPage(EXIT); + + // Timer should disable after firing + // This is redundant, as onBackground() will also disable + return OSThread::disable(); +} + +// Perform action for a menu item, then change page +// Behaviors for MenuActions are defined here +void InkHUD::MenuApplet::execute(MenuItem item) +{ + // Perform an action + // ------------------ + switch (item.action) { + + // Open a submenu without performing any action + // Also handles exit + case NO_ACTION: + break; + + case NEXT_TILE: + // Note performed manually; + // WindowManager::nextTile is raised by aux button press only, and will interact poorly with the menu + settings.userTiles.focused = (settings.userTiles.focused + 1) % settings.userTiles.count; + windowManager->changeLayout(); + cursor = 0; // No menu item selected, for quick exit after tile swap + cursorShown = false; + break; + + case ROTATE: + settings.rotation = (settings.rotation + 1) % 4; + windowManager->changeLayout(); + // requestUpdate(Drivers::EInk::UpdateTypes::FULL); // Would update regardless; just selecting FULL + break; + + case LAYOUT: + // Todo: smarter incrementing of tile count + settings.userTiles.count++; + + if (settings.userTiles.count == 3) // Skip 3 tiles: not done yet + settings.userTiles.count++; + + if (settings.userTiles.count > settings.userTiles.maxCount) // Loop around if tile count now too high + settings.userTiles.count = 1; + + windowManager->changeLayout(); + // requestUpdate(Drivers::EInk::UpdateTypes::FULL); // Would update regardless; just selecting FULL + break; + + case TOGGLE_APPLET: + settings.userApplets.active[cursor] = !settings.userApplets.active[cursor]; + windowManager->changeActivatedApplets(); + // requestUpdate(Drivers::EInk::UpdateTypes::FULL); // Select FULL, seeing how this action doesn't auto exit + break; + + case ACTIVATE_APPLETS: + // Todo: remove this action? Already handled by TOGGLE_APPLET? + windowManager->changeActivatedApplets(); + break; + + case TOGGLE_AUTOSHOW_APPLET: + // Toggle settings.userApplets.autoshow[] value, via MenuItem::checkState pointer set in populateAutoshowPage() + *items.at(cursor).checkState = !(*items.at(cursor).checkState); + break; + + case TOGGLE_NOTIFICATIONS: + settings.optionalFeatures.notifications = !settings.optionalFeatures.notifications; + break; + + case SET_RECENTS: + // Set value of settings.recentlyActiveSeconds + // Uses menu cursor to read RECENTS_OPTIONS_MINUTES array (defined at top of this file) + assert(cursor < sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0])); + settings.recentlyActiveSeconds = RECENTS_OPTIONS_MINUTES[cursor] * 60; // Menu items are in minutes + break; + + case SHUTDOWN: + LOG_INFO("Shutting down from menu"); + power->shutdown(); + // Menu is then sent to background via onShutdown + break; + + case TOGGLE_BATTERY_ICON: + windowManager->toggleBatteryIcon(); + break; + + case TOGGLE_BACKLIGHT: + // Note: backlight is already on in this situation + // We're marking that it should *remain* on once menu closes + assert(backlight); + if (backlight->isLatched()) + backlight->off(); + else + backlight->latch(); + break; + + default: + LOG_WARN("Action not implemented"); + } + + // Move to next page, as defined for the MenuItem + showPage(item.nextPage); +} + +// Display a new page of MenuItems +// May reload same page, or exit menu applet entirely +// Fills the MenuApplet::items vector +void InkHUD::MenuApplet::showPage(MenuPage page) +{ + items.clear(); + + switch (page) { + case ROOT: + // Optional: next applet + if (settings.optionalMenuItems.nextTile && settings.userTiles.count > 1) + items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown + + // items.push_back(MenuItem("Send", MenuPage::SEND)); // TODO + items.push_back(MenuItem("Options", MenuPage::OPTIONS)); + // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO + items.push_back(MenuItem("Save & Shutdown", MenuAction::SHUTDOWN)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case SEND: + items.push_back(MenuItem("Send Message", MenuPage::EXIT)); + items.push_back(MenuItem("Send NodeInfo", MenuAction::SEND_NODEINFO)); + items.push_back(MenuItem("Send Position", MenuAction::SEND_POSITION)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case OPTIONS: + // Optional: backlight + if (settings.optionalMenuItems.backlight) { + assert(backlight); + items.push_back(MenuItem(backlight->isLatched() ? "Backlight Off" : "Keep Backlight On", // Label + MenuAction::TOGGLE_BACKLIGHT, // Action + MenuPage::EXIT // Exit once complete + )); + } + + items.push_back(MenuItem("Applets", MenuPage::APPLETS)); + items.push_back(MenuItem("Auto-show", MenuPage::AUTOSHOW)); + items.push_back(MenuItem("Recents Duration", MenuPage::RECENTS)); + if (settings.userTiles.maxCount > 1) + items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS)); + items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS)); + items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS, + &settings.optionalFeatures.notifications)); + items.push_back( + MenuItem("Battery Icon", MenuAction::TOGGLE_BATTERY_ICON, MenuPage::OPTIONS, &settings.optionalFeatures.batteryIcon)); + + // TODO - GPS and Wifi switches + /* + // Optional: has GPS + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) + items.push_back(MenuItem("Enable GPS", MenuPage::EXIT)); // TODO + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) + items.push_back(MenuItem("Disable GPS", MenuPage::EXIT)); // TODO + + // Optional: using wifi + if (!config.bluetooth.enabled) + items.push_back(MenuItem("Enable Bluetooth", MenuPage::EXIT)); // TODO: escape hatch if wifi configured wrong + */ + + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case APPLETS: + populateAppletPage(); + items.push_back(MenuItem("Exit", MenuAction::ACTIVATE_APPLETS)); + break; + + case AUTOSHOW: + populateAutoshowPage(); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case RECENTS: + populateRecentsPage(); + break; + + case EXIT: + sendToBackground(); // Menu applet dismissed, allow normal behavior to resume + // requestUpdate(Drivers::EInk::UpdateTypes::FULL); + break; + + default: + LOG_WARN("Page not implemented"); + } + + // Reset the cursor, unless reloading same page + // (or now out-of-bounds) + if (page != currentPage || cursor >= items.size()) { + cursor = 0; + + // ROOT menu has special handling: unselected at first, to emphasise the system info panel + if (page == ROOT) + cursorShown = false; + } + + // Remember which page we are on now + currentPage = page; +} + +void InkHUD::MenuApplet::onRender() +{ + if (items.size() == 0) + LOG_ERROR("Empty Menu"); + + // Testing only + setFont(fontSmall); + + // Dimensions for the slots where we will draw menuItems + const float padding = 0.05; + const uint16_t itemH = fontSmall.lineHeight() * 2; + const int16_t itemW = width() - X(padding) - X(padding); + const int16_t itemL = X(padding); + const int16_t itemR = X(1 - padding); + int16_t itemT = 0; // Top (y px of current slot). Incremented as we draw. Adjusted to fit system info panel on ROOT menu. + + // How many full menuItems will fit on screen + uint8_t slotCount = (height() - itemT) / itemH; + + // System info panel at the top of the menu + // ========================================= + + uint16_t &siH = systemInfoPanelHeight; // System info - height. Calculated at onForeground + const uint8_t slotsObscured = ceilf(siH / (float)itemH); // How many slots are obscured by system info panel + + // System info - top + // Remain at 0px, until cursor reaches bottom of screen, then begin to scroll off screen. + // This is the same behavior we expect from the non-root menus. + // Implementing this with the systemp panel is slightly annoying though, + // and required adding the MenuApplet::getSystemInfoPanelHeight method + int16_t siT; + if (cursor < slotCount - slotsObscured - 1) // (Minus 1: comparing zero based index with a count) + siT = 0; + else + siT = 0 - ((cursor - (slotCount - slotsObscured - 1)) * itemH); + + // If showing ROOT menu, + // and the panel isn't yet scrolled off screen top + if (currentPage == ROOT) { + drawSystemInfoPanel(0, siT, width()); // Draw the panel. + itemT = max(siT + siH, 0); // Offset the first menu entry, so menu starts below the system info panel + } + + // Draw menu items + // =================== + + // Which item will be drawn to the top-most slot? + // Initially, this is the item 0, but may increase once we begin scrolling + uint8_t firstItem; + if (cursor < slotCount) + firstItem = 0; + else + firstItem = cursor - (slotCount - 1); + + // Which item will be drawn to the bottom-most slot? + // This may be beyond the slot-count, to draw a partially off-screen item below the bottom-most slow + // This may be less than the slot-count, if we are reaching the end of the menuItems + uint8_t lastItem = min((uint8_t)firstItem + slotCount, (uint8_t)items.size() - 1); + + // -- Loop: draw each (visible) menu item -- + for (uint8_t i = firstItem; i <= lastItem; i++) { + // Grab the menuItem + MenuItem item = items.at(i); + + // Center-line for the text + int16_t center = itemT + (itemH / 2); + + if (cursorShown && i == cursor) + drawRect(itemL, itemT, itemW, itemH, BLACK); + printAt(itemL + X(padding), center, item.label, LEFT, MIDDLE); + + // Testing only: circle instead of check box + if (item.checkState) { + const uint16_t cbWH = fontSmall.lineHeight(); // Checbox: width / height + const int16_t cbL = itemR - X(padding) - cbWH; // Checkbox: left + const int16_t cbT = center - (cbWH / 2); // Checkbox : top + // Checkbox ticked + if (*(item.checkState)) { + drawRect(cbL, cbT, cbWH, cbWH, BLACK); + // First point of tick: pen down + const int16_t t1Y = center; + const int16_t t1X = cbL + 3; + // Second point of tick: base + const int16_t t2Y = center + (cbWH / 2) - 2; + const int16_t t2X = cbL + (cbWH / 2); + // Third point of tick: end of tail + const int16_t t3Y = center - (cbWH / 2) - 2; + const int16_t t3X = cbL + cbWH + 2; + // Draw twice: faux bold + drawLine(t1X, t1Y, t2X, t2Y, BLACK); + drawLine(t2X, t2Y, t3X, t3Y, BLACK); + drawLine(t1X + 1, t1Y, t2X + 1, t2Y, BLACK); + drawLine(t2X + 1, t2Y, t3X + 1, t3Y, BLACK); + } + // Checkbox ticked + else + drawRect(cbL, cbT, cbWH, cbWH, BLACK); + } + + // Increment the y value (top) as we go + itemT += itemH; + } +} + +void InkHUD::MenuApplet::onButtonShortPress() +{ + // Push the auto-close timer back + OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + + // Move menu cursor to next entry, then update + if (cursorShown) + cursor = (cursor + 1) % items.size(); + else + cursorShown = true; + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::MenuApplet::onButtonLongPress() +{ + // Push the auto-close timer back + OSThread::setIntervalFromNow(MENU_TIMEOUT_SEC * 1000UL); + + if (cursorShown) + execute(items.at(cursor)); + else + showPage(MenuPage::EXIT); // Special case: Peek at root-menu; longpress again to close + + // If we didn't already request a specialized update, when handling a menu action, + // then perform the usual fast update. + // FAST keeps things responsive: important because we're dealing with user input + if (!wantsToRender()) + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +// Dynamically create MenuItem entries for activating / deactivating Applets, for the "Applet Selection" submenu +void InkHUD::MenuApplet::populateAppletPage() +{ + assert(items.size() == 0); + + for (uint8_t i = 0; i < windowManager->getAppletCount(); i++) { + const char *name = windowManager->getAppletName(i); + bool *isActive = &(settings.userApplets.active[i]); + items.push_back(MenuItem(name, MenuAction::TOGGLE_APPLET, MenuPage::APPLETS, isActive)); + } +} + +// Dynamically create MenuItem entries for selecting which applets will automatically come to foreground when they have new data +// We only populate this menu page with applets which are actually active +// We use the MenuItem::checkState pointer to toggle the setting in MenuApplet::execute. Bit of a hack, but convenient. +void InkHUD::MenuApplet::populateAutoshowPage() +{ + assert(items.size() == 0); + + for (uint8_t i = 0; i < windowManager->getAppletCount(); i++) { + // Only add a menu item if applet is active + if (settings.userApplets.active[i]) { + const char *name = windowManager->getAppletName(i); + bool *isActive = &(settings.userApplets.autoshow[i]); + items.push_back(MenuItem(name, MenuAction::TOGGLE_AUTOSHOW_APPLET, MenuPage::AUTOSHOW, isActive)); + } + } +} + +void InkHUD::MenuApplet::populateRecentsPage() +{ + // How many values are shown for use to choose from + constexpr uint8_t optionCount = sizeof(RECENTS_OPTIONS_MINUTES) / sizeof(RECENTS_OPTIONS_MINUTES[0]); + + // Create an entry for each item in RECENTS_OPTIONS_MINUTES array + // (Defined at top of this file) + for (uint8_t i = 0; i < optionCount; i++) { + std::string label = to_string(RECENTS_OPTIONS_MINUTES[i]) + " mins"; + items.push_back(MenuItem(label.c_str(), MenuAction::SET_RECENTS, MenuPage::EXIT)); + } +} + +// Renders the panel shown at the top of the root menu. +// Displays the clock, and several other pieces of instantaneous system info, +// which we'd prefer not to have displayed in a normal applet, as they update too frequently. +void InkHUD::MenuApplet::drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width, uint16_t *renderedHeight) +{ + // Reset the height + // We'll add to this as we add elements + uint16_t height = 0; + + // Clock (potentially) + // ==================== + std::string clockString = getTimeString(); + if (clockString.length() > 0) { + setFont(fontLarge); + printAt(width / 2, top, clockString, CENTER, TOP); + + height += fontLarge.lineHeight(); + height += fontLarge.lineHeight() * 0.1; // Padding below clock + } + + // Stats + // =================== + + setFont(fontSmall); + + // Position of the label row for the system info + const int16_t labelT = top + height; + height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing + + // Position of the data row for the system info + const int16_t valT = top + height; + height += fontSmall.lineHeight() * 1.1; // Slightly increased spacing (between bottom line and divider) + + // Position of divider between the info panel and the menu entries + const int16_t divY = top + height; + height += fontSmall.lineHeight() * 0.2; // Padding *below* the divider. (Above first menu item) + + // Create a variable number of columns + // Either 3 or 4, depending on whether we have GPS + // Todo + constexpr uint8_t N_COL = 3; + int16_t colL[N_COL]; + int16_t colC[N_COL]; + int16_t colR[N_COL]; + for (uint8_t i = 0; i < N_COL; i++) { + colL[i] = left + ((width / N_COL) * i); + colC[i] = colL[i] + ((width / N_COL) / 2); + colR[i] = colL[i] + (width / N_COL); + } + + // Info blocks, left to right + + // Voltage + float voltage = powerStatus->getBatteryVoltageMv() / 1000.0; + char voltageStr[6]; // "XX.XV" + sprintf(voltageStr, "%.1fV", voltage); + printAt(colC[0], labelT, "Bat", CENTER, TOP); + printAt(colC[0], valT, voltageStr, CENTER, TOP); + + // Divider + for (int16_t y = valT; y <= divY; y += 3) + drawPixel(colR[0], y, BLACK); + + // Channel Util + char chUtilStr[4]; // "XX%" + sprintf(chUtilStr, "%2.f%%", airTime->channelUtilizationPercent()); + printAt(colC[1], labelT, "Ch", CENTER, TOP); + printAt(colC[1], valT, chUtilStr, CENTER, TOP); + + // Divider + for (int16_t y = valT; y <= divY; y += 3) + drawPixel(colR[1], y, BLACK); + + // Duty Cycle (AirTimeTx) + char dutyUtilStr[4]; // "XX%" + sprintf(dutyUtilStr, "%2.f%%", airTime->utilizationTXPercent()); + printAt(colC[2], labelT, "Duty", CENTER, TOP); + printAt(colC[2], valT, dutyUtilStr, CENTER, TOP); + + /* + // Divider + for (int16_t y = valT; y <= divY; y += 3) + drawPixel(colR[2], y, BLACK); + + // GPS satellites - todo + printAt(colC[3], labelT, "Sats", CENTER, TOP); + printAt(colC[3], valT, "ToDo", CENTER, TOP); + */ + + // Horizontal divider, at bottom of system info panel + for (int16_t x = 0; x < width; x += 2) // Divider, centered in the padding between first system panel and first item + drawPixel(x, divY, BLACK); + + if (renderedHeight != nullptr) + *renderedHeight = height; +} + +// Get the height of the the panel drawn at the top of the menu +// This is inefficient, as we do actually have to render the panel to determine the height +// It solves a catch-22 situtation, where slotCount needs to know panel height, and panel height needs to know slotCount +uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight() +{ + // Render *waay* off screen + uint16_t height = 0; + drawSystemInfoPanel(INT16_MIN, INT16_MIN, 1, &height); + + return height; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h new file mode 100644 index 000000000..f2e9b3947 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h @@ -0,0 +1,60 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "configuration.h" + +#include "graphics/niche/Drivers/Backlight/LatchingBacklight.h" +#include "graphics/niche/InkHUD/Applet.h" +#include "graphics/niche/InkHUD/WindowManager.h" + +#include "./MenuItem.h" +#include "./MenuPage.h" + +#include "concurrency/OSThread.h" + +namespace NicheGraphics::InkHUD +{ + +class Applet; + +class MenuApplet : public Applet, public concurrency::OSThread +{ + public: + MenuApplet(); + void onActivate() override; + void onForeground() override; + void onBackground() override; + void onButtonShortPress() override; + void onButtonLongPress() override; + void onRender() override; + + void show(Tile *t); // Open the menu, onto a user tile + + protected: + int32_t runOnce() override; + + void execute(MenuItem item); // Perform the MenuAction associated with a MenuItem, if any + void showPage(MenuPage page); // Load and display a MenuPage + void populateAppletPage(); // Dynamically create MenuItems for toggling loaded applets + void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow + void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds + uint16_t getSystemInfoPanelHeight(); + void drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width, + uint16_t *height = nullptr); // Info panel at top of root menu + + MenuPage currentPage; + uint8_t cursor = 0; // Which menu item is currently highlighted + bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection) + + uint16_t systemInfoPanelHeight = 0; // Need to know before we render + + std::vector items; // MenuItems for the current page. Filled by ShowPage + + Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu + + WindowManager *windowManager = nullptr; // Convenient access to the InkHUD::WindowManager singleton + Drivers::LatchingBacklight *backlight = nullptr; // Convenient access to the backlight singleton +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuItem.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuItem.h new file mode 100644 index 000000000..c74fe3d8a --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuItem.h @@ -0,0 +1,47 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +One item of a MenuPage, in InkHUD::MenuApplet + +Added to MenuPages in InkHUD::showPage + +- May open a submenu or exit +- May perform an action +- May toggle a bool value, shown by a checkbox + +*/ + +#pragma once + +#include "configuration.h" + +#include "./MenuAction.h" +#include "./MenuPage.h" + +namespace NicheGraphics::InkHUD +{ + +// One item of a MenuPage +class MenuItem +{ + public: + std::string label; + MenuAction action = NO_ACTION; + MenuPage nextPage = EXIT; + bool *checkState = nullptr; + + // Various constructors, depending on the intended function of the item + + MenuItem(const char *label, MenuPage nextPage) : label(label), nextPage(nextPage) {} + MenuItem(const char *label, MenuAction action) : label(label), action(action) {} + MenuItem(const char *label, MenuAction action, MenuPage nextPage) : label(label), action(action), nextPage(nextPage) {} + MenuItem(const char *label, MenuAction action, MenuPage nextPage, bool *checkState) + : label(label), action(action), nextPage(nextPage), checkState(checkState) + { + } +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h new file mode 100644 index 000000000..d2314e83b --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h @@ -0,0 +1,30 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Sub-menu for InkHUD::MenuApplet +Structure of the menu is defined in InkHUD::showPage + +*/ + +#pragma once + +#include "configuration.h" + +namespace NicheGraphics::InkHUD +{ + +// Sub-menu for MenuApplet +enum MenuPage : uint8_t { + ROOT, // Initial menu page + SEND, + OPTIONS, + APPLETS, + AUTOSHOW, + RECENTS, // Select length of "recentlyActiveSeconds" + EXIT, // Dismiss the menu applet +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h new file mode 100644 index 000000000..d8c4f8366 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h @@ -0,0 +1,40 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +A notification which might be displayed by the NotificationApplet + +An instance of this class is offered to Applets via Applet::approveNotification, in case they want to veto the notification. +An Applet should veto a notification if it is already displaying the same info which the notification would convey. + +*/ + +#pragma once + +#include "configuration.h" + +namespace NicheGraphics::InkHUD +{ + +class Notification +{ + public: + enum Type : uint8_t { NOTIFICATION_MESSAGE_BROADCAST, NOTIFICATION_MESSAGE_DIRECT, NOTIFICATION_BATTERY } type; + + uint32_t timestamp; + + uint8_t getChannel() { return channel; } + uint32_t getSender() { return sender; } + uint8_t getBatteryPercentage() { return batteryPercentage; } + + friend class NotificationApplet; + + protected: + uint8_t channel; + uint32_t sender; + uint8_t batteryPercentage; +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp new file mode 100644 index 000000000..90ff25ede --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -0,0 +1,219 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./NotificationApplet.h" + +#include "./Notification.h" + +#include "rtc.h" + +using namespace NicheGraphics; + +void InkHUD::NotificationApplet::onActivate() +{ + textMessageObserver.observe(textMessageModule); +} + +// Note: This applet probably won't ever be deactivated +void InkHUD::NotificationApplet::onDeactivate() +{ + textMessageObserver.unobserve(textMessageModule); +} + +// Collect meta-info about the text message, and ask for approval for the notification +// No need to save the message itself; we can use the cached InkHUD::latestMessage data during render() +int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) +{ + // System applets are always active + assert(isActive()); + + // Abort if feature disabled + // This is a bit clumsy, but avoids complicated handling when the feature is enabled / disabled + if (!settings.optionalFeatures.notifications) + return 0; + + // Abort if this is an outgoing message + if (getFrom(p) == nodeDB->getNodeNum()) + return 0; + + // Abort if message was only an "emoji reaction" + // Possibly some implemetation of this in future? + if (p->decoded.emoji) + return 0; + + Notification n; + n.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time + + // Gather info: in-channel message + if (isBroadcast(p->to)) { + n.type = Notification::Type::NOTIFICATION_MESSAGE_BROADCAST; + n.channel = p->channel; + } + + // Gather info: DM + else { + n.type = Notification::Type::NOTIFICATION_MESSAGE_DIRECT; + n.sender = p->from; + } + + // Check if we should display the notification + // A foreground applet might already be displaying this info + hasNotification = true; + currentNotification = n; + if (isApproved()) { + bringToForeground(); + WindowManager::getInstance()->forceUpdate(); + } else + hasNotification = false; // Clear the pending notification: it was rejected + + // Return zero: no issues here, carry on notifying other observers! + return 0; +} + +void InkHUD::NotificationApplet::onRender() +{ + // Clear the region beneath the tile + // Most applets are drawing onto an empty frame buffer and don't need to do this + // We do need to do this with the battery though, as it is an "overlay" + fillRect(0, 0, width(), height(), WHITE); + + setFont(fontSmall); + + // Padding (horizontal) + const uint16_t padW = 4; + + // Main border + drawRect(0, 0, width(), height(), BLACK); + // drawRect(1, 1, width() - 2, height() - 2, BLACK); + + // Timestamp (potentially) + // ==================== + std::string ts = getTimeString(currentNotification.timestamp); + uint16_t tsW = 0; + int16_t divX = 0; + + // Timestamp available + if (ts.length() > 0) { + tsW = getTextWidth(ts); + divX = padW + tsW + padW; + + hatchRegion(0, 0, divX, height(), 2, BLACK); // Fill with a dark background + drawLine(divX, 0, divX, height() - 1, BLACK); // Draw divider between timestamp and main text + + setCrop(1, 1, divX - 1, height() - 2); + + // Drop shadow + setTextColor(WHITE); + printThick(padW + (tsW / 2), height() / 2, ts, 4, 4); + + // Bold text + setTextColor(BLACK); + printThick(padW + (tsW / 2), height() / 2, ts, 2, 1); + } + + // Main text + // ===================== + + // Background fill + // - medium dark (1/3) + hatchRegion(divX, 0, width() - divX - 1, height(), 3, BLACK); + + uint16_t availableWidth = width() - divX - padW; + std::string text = getNotificationText(availableWidth); + + int16_t textM = divX + padW + (getTextWidth(text) / 2); + + // Restrict area for printing + // - don't overlap border, or diveder + setCrop(divX + 1, 1, (width() - (divX + 1) - 1), height() - 2); + + // Drop shadow + // - thick white text + setTextColor(WHITE); + printThick(textM, height() / 2, text, 4, 4); + + // Main text + // - faux bold: double width + setTextColor(BLACK); + printThick(textM, height() / 2, text, 2, 1); +} + +// Ask the WindowManager to check whether any displayed applets are already displaying the info from this notification +// Called internally when we first get a "notifiable event", and then again before render, +// in case autoshow swapped which applet was displayed +bool InkHUD::NotificationApplet::isApproved() +{ + // Instead of an assert + if (!hasNotification) { + LOG_WARN("No notif to approve"); + return false; + } + + return WindowManager::getInstance()->approveNotification(currentNotification); +} + +// Mark that the notification should no-longer be rendered +// In addition to calling thing method, code needs to request a re-render of all applets +void InkHUD::NotificationApplet::dismiss() +{ + sendToBackground(); + hasNotification = false; + // Not requesting update directly from this method, + // as it is used to dismiss notifications which have been made redundant by autoshow settings, before they are ever drawn +} + +// Get a string for the main body text of a notification +// Formatted to suit screen width +// Takes info from InkHUD::currentNotification +std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvailable) +{ + assert(hasNotification); + + std::string text; + + // Text message + // ============== + + if (IS_ONE_OF(currentNotification.type, Notification::Type::NOTIFICATION_MESSAGE_DIRECT, + Notification::Type::NOTIFICATION_MESSAGE_BROADCAST)) { + + // Although we are handling DM and broadcast notifications together, we do need to treat them slightly differently + bool isBroadcast = currentNotification.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST; + + // Pick source of message + MessageStore::Message *message = isBroadcast ? &latestMessage.broadcast : &latestMessage.dm; + + // Find info about the sender + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender); + + // Leading tag (channel vs. DM) + text += isBroadcast ? "From:" : "DM: "; + + // Sender id + if (node && node->has_user) + text += node->user.short_name; + else + text += hexifyNodeNum(message->sender); + + // Check if text fits + // - use a longer string, if we have the space + if (getTextWidth(text) < widthAvailable * 0.5) { + text.clear(); + + // Leading tag (channel vs. DM) + text += isBroadcast ? "Msg from " : "DM from "; + + // Sender id + if (node && node->has_user) + text += node->user.short_name; + else + text += hexifyNodeNum(message->sender); + + text += ": "; + text += message->text; + } + } + + return text; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h new file mode 100644 index 000000000..c4d36a4fd --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h @@ -0,0 +1,49 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Pop-up notification bar, on screen top edge +Displays information we feel is important, but which is not shown on currently focussed applet(s) +E.g.: messages, while viewing map, etc + +Feature should be optional; enable disable via on-screen menu + +*/ + +#pragma once + +#include "configuration.h" + +#include "concurrency/OSThread.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class NotificationApplet : public Applet +{ + public: + void onRender() override; + void onActivate() override; + void onDeactivate() override; + + int onReceiveTextMessage(const meshtastic_MeshPacket *p); + + bool isApproved(); // Does a foreground applet make notification redundant? + void dismiss(); // Close the Notification Popup + + protected: + // Get notified when a new text message arrives + CallbackObserver textMessageObserver = + CallbackObserver(this, &NotificationApplet::onReceiveTextMessage); + + std::string getNotificationText(uint16_t widthAvailable); // Get text for notification, to suit screen width + + bool hasNotification = false; // Only used for assert. Todo: remove? + Notification currentNotification; // Set when something notification-worthy happens. Used by render() +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.cpp new file mode 100644 index 000000000..457fa0f3f --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.cpp @@ -0,0 +1,96 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./PairingApplet.h" + +using namespace NicheGraphics; + +InkHUD::PairingApplet::PairingApplet() +{ + // Grab the window manager singleton, for convenience + windowManager = WindowManager::getInstance(); +} + +void InkHUD::PairingApplet::onRender() +{ + // Header + setFont(fontLarge); + printAt(X(0.5), Y(0.25), "Bluetooth", CENTER, BOTTOM); + setFont(fontSmall); + printAt(X(0.5), Y(0.25), "Enter this code", CENTER, TOP); + + // Passkey + setFont(fontLarge); + printThick(X(0.5), Y(0.5), passkey.substr(0, 3) + " " + passkey.substr(3), 3, 2); + + // Device's bluetooth name, if it will fit + setFont(fontSmall); + std::string name = "Name: " + std::string(getDeviceName()); + if (getTextWidth(name) > width()) // Too wide, try without the leading "Name: " + name = std::string(getDeviceName()); + if (getTextWidth(name) < width()) // Does it fit? + printAt(X(0.5), Y(0.75), name, CENTER, MIDDLE); +} + +void InkHUD::PairingApplet::onActivate() +{ + bluetoothStatusObserver.observe(&bluetoothStatus->onNewStatus); +} + +void InkHUD::PairingApplet::onDeactivate() +{ + bluetoothStatusObserver.unobserve(&bluetoothStatus->onNewStatus); +} + +void InkHUD::PairingApplet::onForeground() +{ + // If another applet has locked the display, ask it to exit + Applet *other = windowManager->whoLocked(); + if (other != nullptr) + other->sendToBackground(); + + windowManager->claimFullscreen(this); // Take ownership of the fullscreen tile + windowManager->lock(this); // Prevent user applets from requesting update +} +void InkHUD::PairingApplet::onBackground() +{ + windowManager->releaseFullscreen(); // Relinquish ownership of the fullscreen tile + windowManager->unlock(this); // Allow normal user applet update requests to resume + + // Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background + // Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case + windowManager->forceUpdate(EInk::UpdateTypes::FULL); +} + +int InkHUD::PairingApplet::onBluetoothStatusUpdate(const meshtastic::Status *status) +{ + // The standard Meshtastic convention is to pass these "generic" Status objects, + // check their type, and then cast them. + // We'll mimic that behavior, just to keep in line with the other Statuses, + // even though I'm not sure what the original reason for jumping through these extra hoops was. + assert(status->getStatusType() == STATUS_TYPE_BLUETOOTH); + meshtastic::BluetoothStatus *bluetoothStatus = (meshtastic::BluetoothStatus *)status; + + // When pairing begins + if (bluetoothStatus->getConnectionState() == meshtastic::BluetoothStatus::ConnectionState::PAIRING) { + // Store the passkey for rendering + passkey = bluetoothStatus->getPasskey(); + + // Make sure no other system applets have a lock on the display + // Boot screen, menu, etc + Applet *lockOwner = windowManager->whoLocked(); + if (lockOwner) + lockOwner->sendToBackground(); + + // Show pairing screen + bringToForeground(); + } + + // When pairing ends + // or rather, when something changes, and we shouldn't be showing the pairing screen + else if (isForeground()) + sendToBackground(); + + return 0; // No special result to report back to Observable +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.h b/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.h new file mode 100644 index 000000000..ce420e68b --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Pairing/PairingApplet.h @@ -0,0 +1,43 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Shows the Bluetooth passkey during pairing + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class PairingApplet : public Applet +{ + public: + PairingApplet(); + + void onRender() override; + void onActivate() override; + void onDeactivate() override; + void onForeground() override; + void onBackground() override; + + int onBluetoothStatusUpdate(const meshtastic::Status *status); + + protected: + // Get notified when status of the Bluetooth connection changes + CallbackObserver bluetoothStatusObserver = + CallbackObserver(this, &PairingApplet::onBluetoothStatusUpdate); + + std::string passkey = ""; // Passkey. Six digits, possibly with leading zeros + + WindowManager *windowManager = nullptr; // For convenience. Set in constructor. +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.cpp new file mode 100644 index 000000000..4f66593b9 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.cpp @@ -0,0 +1,21 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./PlaceholderApplet.h" + +using namespace NicheGraphics; + +InkHUD::PlaceholderApplet::PlaceholderApplet() +{ + // Because this applet sometimes gets processed as if it were a bonafide user applet, + // it's probably better that we do give it a human readable name, just in case it comes up later. + // For genuine user applets, this is set by WindowManager::addApplet + Applet::name = "Placeholder"; +} + +void InkHUD::PlaceholderApplet::onRender() +{ + // This placeholder applet fills its area with sparse diagonal lines + hatchRegion(0, 0, width(), height(), 8, BLACK); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.h b/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.h new file mode 100644 index 000000000..e5106105c --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Placeholder/PlaceholderApplet.h @@ -0,0 +1,30 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Shown when a tile doesn't have any other valid Applets +Fills the area with diagonal lines + +*/ + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class PlaceholderApplet : public Applet +{ + public: + PlaceholderApplet(); + void onRender() override; + + // Note: onForeground, onBackground, and wantsToRender are not meaningful for this applet. + // The window manager decides when and where it should be rendered + // It may be drawn to several different tiles during on WindowManager::render call +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp new file mode 100644 index 000000000..123ff7434 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp @@ -0,0 +1,235 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./TipsApplet.h" + +using namespace NicheGraphics; + +InkHUD::TipsApplet::TipsApplet() +{ + // Grab the window manager singleton, for convenience + windowManager = WindowManager::getInstance(); +} + +void InkHUD::TipsApplet::onRender() +{ + switch (tipQueue.front()) { + case Tip::WELCOME: + renderWelcome(); + break; + + case Tip::FINISH_SETUP: { + setFont(fontLarge); + printAt(0, 0, "Tip: Finish Setup"); + + setFont(fontSmall); + int16_t cursorY = fontLarge.lineHeight() * 1.5; + printAt(0, cursorY, "- connect antenna"); + + cursorY += fontSmall.lineHeight() * 1.2; + printAt(0, cursorY, "- connect a client app"); + + // Only if region not set + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + cursorY += fontSmall.lineHeight() * 1.2; + printAt(0, cursorY, "- set region"); + } + + // Only if tz not set + if (!(*config.device.tzdef && config.device.tzdef[0] != 0)) { + cursorY += fontSmall.lineHeight() * 1.2; + printAt(0, cursorY, "- set timezone"); + } + + cursorY += fontSmall.lineHeight() * 1.5; + printAt(0, cursorY, "More info at meshtastic.org"); + + setFont(fontSmall); + printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + } break; + + case Tip::SAFE_SHUTDOWN: { + setFont(fontLarge); + printAt(0, 0, "Tip: Shutdown"); + + setFont(fontSmall); + std::string shutdown; + shutdown += "Before removing power, please shutdown from InkHUD menu, or a client app. \n"; + shutdown += "\n"; + shutdown += "This ensures data is saved."; + printWrapped(0, fontLarge.lineHeight() * 1.5, width(), shutdown); + + printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + + } break; + + case Tip::CUSTOMIZATION: { + setFont(fontLarge); + printAt(0, 0, "Tip: Customization"); + + setFont(fontSmall); + printWrapped(0, fontLarge.lineHeight() * 1.5, width(), + "Configure & control display with the InkHUD menu. Optional features, layout, rotation, and more."); + + printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + } break; + + case Tip::BUTTONS: { + setFont(fontLarge); + printAt(0, 0, "Tip: Buttons"); + + setFont(fontSmall); + int16_t cursorY = fontLarge.lineHeight() * 1.5; + + printAt(0, cursorY, "User Button"); + cursorY += fontSmall.lineHeight() * 1.2; + printAt(0, cursorY, "- short press: next"); + cursorY += fontSmall.lineHeight() * 1.2; + printAt(0, cursorY, "- long press: select / open menu"); + cursorY += fontSmall.lineHeight() * 1.5; + + printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + } break; + + case Tip::ROTATION: { + setFont(fontLarge); + printAt(0, 0, "Tip: Rotation"); + + setFont(fontSmall); + printWrapped(0, fontLarge.lineHeight() * 1.5, width(), + "To rotate the display, use the InkHUD menu. Long-press the user button > Options > Rotate."); + + printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM); + + // Revert the "flip screen" setting, preventing this message showing again + config.display.flip_screen = false; + nodeDB->saveToDisk(SEGMENT_DEVICESTATE); + } break; + } +} + +// This tip has its own render method, only because it's a big block of code +// Didn't want to clutter up the switch in onRender too much +void InkHUD::TipsApplet::renderWelcome() +{ + uint16_t padW = X(0.05); + + // Block 1 - logo & title + // ======================== + + // Logo size + uint16_t logoWLimit = X(0.3); + uint16_t logoHLimit = Y(0.3); + uint16_t logoW = getLogoWidth(logoWLimit, logoHLimit); + uint16_t logoH = getLogoHeight(logoWLimit, logoHLimit); + + // Title size + setFont(fontLarge); + std::string title; + if (width() >= 200) // Future proofing: hide if *tiny* display + title = "meshtastic.org"; + uint16_t titleW = getTextWidth(title); + + // Center the block + // Desired effect: equal margin from display edge for logo left and title right + int16_t block1Y = Y(0.3); + int16_t block1CX = X(0.5) + (logoW / 2) - (titleW / 2); + int16_t logoCX = block1CX - (logoW / 2) - (padW / 2); + int16_t titleCX = block1CX + (titleW / 2) + (padW / 2); + + // Draw block + drawLogo(logoCX, block1Y, logoW, logoH); + printAt(titleCX, block1Y, title, CENTER, MIDDLE); + + // Block 2 - subtitle + // ======================= + setFont(fontSmall); + std::string subtitle = "InkHUD"; + if (width() >= 200) + subtitle += " - A Heads-Up Display"; // Future proofing: narrower for tiny display + printAt(X(0.5), Y(0.6), subtitle, CENTER, MIDDLE); + + // Block 3 - press to continue + // ============================ + printAt(X(0.5), Y(1), "Press button to continue", CENTER, BOTTOM); +} + +// Grab fullscreen tile, and lock the window manager, when applet is shown +void InkHUD::TipsApplet::onForeground() +{ + windowManager->lock(this); + windowManager->claimFullscreen(this); +} + +void InkHUD::TipsApplet::onBackground() +{ + windowManager->releaseFullscreen(); + windowManager->unlock(this); +} + +void InkHUD::TipsApplet::onActivate() +{ + // Decide which tips (if any) should be shown to user after the boot screen + + // Welcome screen + if (settings.tips.firstBoot) + tipQueue.push_back(Tip::WELCOME); + + // Region and/or Timezone + // Shown until sets region and tz + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET || + !(*config.device.tzdef && config.device.tzdef[0] != 0)) + tipQueue.push_back(Tip::FINISH_SETUP); + + // Shutdown info + // Shown until user performs one valid shutdown + if (!settings.tips.safeShutdownSeen) + tipQueue.push_back(Tip::SAFE_SHUTDOWN); + + // Using the UI + if (settings.tips.firstBoot) { + tipQueue.push_back(Tip::CUSTOMIZATION); + tipQueue.push_back(Tip::BUTTONS); + } + + // Catch an incorrect attempt at rotating display + if (config.display.flip_screen) + tipQueue.push_back(Tip::ROTATION); + + // Applet will be brought to foreground when boot screen closes, via TipsApplet::onLockAvailable +} + +// While our applet has the window manager locked, we will receive the button input +void InkHUD::TipsApplet::onButtonShortPress() +{ + tipQueue.pop_front(); + + // All tips done + if (tipQueue.empty()) { + // Record that user has now seen the "tutorial" set of tips + // Don't show them on subsequent boots + if (settings.tips.firstBoot) { + settings.tips.firstBoot = false; + saveDataToFlash(); + } + + // Close applet, and full refresh to clean the screen + // Need to force update, because our request would be ignored otherwise, as we are now background + sendToBackground(); + windowManager->forceUpdate(EInk::UpdateTypes::FULL); + } + + // More tips left + else + requestUpdate(); +} + +// If the wm lock has just become availale (rendering, input), and we've still got tips, grab it! +// This situation would arise if bluetooth pairing occurs while TipsApplet was already shown (after pairing) +// Note: this event is only raised when *other* applets unlock the window manager +void InkHUD::TipsApplet::onLockAvailable() +{ + if (!tipQueue.empty()) + bringToForeground(); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h new file mode 100644 index 000000000..29bcdfa8b --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.h @@ -0,0 +1,52 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Shows info on how to use InkHUD + - tutorial at first boot + - additional tips in certain situation (e.g. bad shutdown, region unset) + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +namespace NicheGraphics::InkHUD +{ + +class TipsApplet : public Applet +{ + protected: + enum class Tip { + WELCOME, + FINISH_SETUP, + SAFE_SHUTDOWN, + CUSTOMIZATION, + BUTTONS, + ROTATION, + }; + + public: + TipsApplet(); + + void onRender() override; + void onActivate() override; + void onForeground() override; + void onBackground() override; + void onButtonShortPress() override; + void onLockAvailable() override; // Reopen if interrupted by bluetooth pairing + + protected: + void renderWelcome(); // Very first screen of tutorial + + std::deque tipQueue; // List of tips to show, one after another + + WindowManager *windowManager = nullptr; // For convenience. Set in constructor. +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp new file mode 100644 index 000000000..1ae313d8a --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp @@ -0,0 +1,133 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./AllMessageApplet.h" + +using namespace NicheGraphics; + +void InkHUD::AllMessageApplet::onActivate() +{ + textMessageObserver.observe(textMessageModule); +} + +void InkHUD::AllMessageApplet::onDeactivate() +{ + textMessageObserver.unobserve(textMessageModule); +} + +// We're not consuming the data passed to this method; +// we're just just using it to trigger a render +int InkHUD::AllMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) +{ + // Abort if applet fully deactivated + // Already handled by onActivate and onDeactivate, but good practice for all applets + if (!isActive()) + return 0; + + // Abort if this is an outgoing message + if (getFrom(p) == nodeDB->getNodeNum()) + return 0; + + // Abort if message was only an "emoji reaction" + // Possibly some implemetation of this in future? + if (p->decoded.emoji) + return 0; + + requestAutoshow(); // Want to become foreground, if permitted + requestUpdate(); // Want to update display, if applet is foreground + + // Return zero: no issues here, carry on notifying other observers! + return 0; +} + +void InkHUD::AllMessageApplet::onRender() +{ + setFont(fontSmall); + + // Find newest message, regardless of whether DM or broadcast + MessageStore::Message *message; + if (latestMessage.wasBroadcast) + message = &latestMessage.broadcast; + else + message = &latestMessage.dm; + + // Short circuit: no text message + if (!message->sender) { + printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE); + return; + } + + // =========================== + // Header (sender, timestamp) + // =========================== + + // Y position for divider + // - between header text and messages + + std::string header; + + // RX Time + // - if valid + std::string timeString = getTimeString(message->timestamp); + if (timeString.length() > 0) { + header += timeString; + header += ": "; + } + + // Sender's id + // - shortname, if available, or + // - node id + meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(message->sender); + if (sender && sender->has_user) { + header += sender->user.short_name; + header += " ("; + header += sender->user.long_name; + header += ")"; + } else + header += hexifyNodeNum(message->sender); + + // Draw a "standard" applet header + drawHeader(header); + + // Fade the right edge of the header, if text spills over edge + uint8_t wF = getFont().lineHeight() / 2; // Width of fade effect + uint8_t hF = getHeaderHeight(); // Height of fade effect + if (getCursorX() > width()) + hatchRegion(width() - wF - 1, 1, wF, hF, 2, WHITE); + + // Dimensions of the header + constexpr int16_t padDivH = 2; + const int16_t headerDivY = Applet::getHeaderHeight() - 1; + + // =================== + // Print message text + // =================== + + // Extra gap below the header + int16_t textTop = headerDivY + padDivH; + + // Determine size if printed large + setFont(fontLarge); + uint32_t textHeight = getWrappedTextHeight(0, width(), message->text); + + // If too large, swap to small font + if (textHeight + textTop > (uint32_t)height()) // (compare signed and unsigned) + setFont(fontSmall); + + // Print text + printWrapped(0, textTop, width(), message->text); +} + +// Don't show notifications for text messages when our applet is displayed +bool InkHUD::AllMessageApplet::approveNotification(Notification &n) +{ + if (n.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST) + return false; + + else if (n.type == Notification::Type::NOTIFICATION_MESSAGE_DIRECT) + return false; + + else + return true; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h new file mode 100644 index 000000000..c74e16196 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h @@ -0,0 +1,49 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Shows the latest incoming text message, as well as sender. +Both broadcast and direct messages will be shown here, from all channels. + +This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages +This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message. +This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage + +We do still receive notifications from the text message module though, +to know when a new message has arrived, and trigger the update. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +#include "modules/TextMessageModule.h" + +namespace NicheGraphics::InkHUD +{ + +class Applet; + +class AllMessageApplet : public Applet +{ + public: + void onRender() override; + + void onActivate() override; + void onDeactivate() override; + int onReceiveTextMessage(const meshtastic_MeshPacket *p); + + bool approveNotification(Notification &n) override; // Which notifications to suppress + + protected: + // Used to register our text message callback + CallbackObserver textMessageObserver = + CallbackObserver(this, &AllMessageApplet::onReceiveTextMessage); +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp new file mode 100644 index 000000000..526b86901 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp @@ -0,0 +1,126 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./DMApplet.h" + +using namespace NicheGraphics; + +void InkHUD::DMApplet::onActivate() +{ + textMessageObserver.observe(textMessageModule); +} + +void InkHUD::DMApplet::onDeactivate() +{ + textMessageObserver.unobserve(textMessageModule); +} + +// We're not consuming the data passed to this method; +// we're just just using it to trigger a render +int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) +{ + // Abort if applet fully deactivated + // Already handled by onActivate and onDeactivate, but good practice for all applets + if (!isActive()) + return 0; + + // Abort if only an "emoji reactions" + // Possibly some implemetation of this in future? + if (p->decoded.emoji) + return 0; + + // If DM (not broadcast) + if (!isBroadcast(p->to)) { + // Want to update display, if applet is foreground + requestUpdate(); + + // If this was an incoming message, suggest that our applet becomes foreground, if permitted + if (getFrom(p) != nodeDB->getNodeNum()) + requestAutoshow(); + } + + // Return zero: no issues here, carry on notifying other observers! + return 0; +} + +void InkHUD::DMApplet::onRender() +{ + setFont(fontSmall); + + // Abort if no text message + if (!latestMessage.dm.sender) { + printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE); + return; + } + + // =========================== + // Header (sender, timestamp) + // =========================== + + // Y position for divider + // - between header text and messages + + std::string header; + + // RX Time + // - if valid + std::string timeString = getTimeString(latestMessage.dm.timestamp); + if (timeString.length() > 0) { + header += timeString; + header += ": "; + } + + // Sender's id + // - shortname, if available, or + // - node id + meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(latestMessage.dm.sender); + if (sender && sender->has_user) { + header += sender->user.short_name; + header += " ("; + header += sender->user.long_name; + header += ")"; + } else + header += hexifyNodeNum(latestMessage.dm.sender); + + // Draw a "standard" applet header + drawHeader(header); + + // Fade the right edge of the header, if text spills over edge + uint8_t wF = getFont().lineHeight() / 2; // Width of fade effect + uint8_t hF = getHeaderHeight(); // Height of fade effect + if (getCursorX() > width()) + hatchRegion(width() - wF - 1, 1, wF, hF, 2, WHITE); + + // Dimensions of the header + constexpr int16_t padDivH = 2; + const int16_t headerDivY = Applet::getHeaderHeight() - 1; + + // =================== + // Print message text + // =================== + + // Extra gap below the header + int16_t textTop = headerDivY + padDivH; + + // Determine size if printed large + setFont(fontLarge); + uint32_t textHeight = getWrappedTextHeight(0, width(), latestMessage.dm.text); + + // If too large, swap to small font + if (textHeight + textTop > (uint32_t)height()) // (compare signed and unsigned) + setFont(fontSmall); + + // Print text + printWrapped(0, textTop, width(), latestMessage.dm.text); +} + +// Don't show notifications for direct messages when our applet is displayed +bool InkHUD::DMApplet::approveNotification(Notification &n) +{ + if (n.type == Notification::Type::NOTIFICATION_MESSAGE_DIRECT) + return false; + + else + return true; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h new file mode 100644 index 000000000..b3dc36e66 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.h @@ -0,0 +1,49 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Shows the latest incoming *Direct Message* (DM), as well as sender. +This compliments the threaded message applets + +This module doesn't doesn't use the devicestate.rx_text_message,' as this is overwritten to contain outgoing messages +This module doesn't collect its own text message. Instead, the WindowManager stores the most recent incoming text message. +This is available to any interested modules (SingeMessageApplet, NotificationApplet etc.) via InkHUD::latestMessage + +We do still receive notifications from the text message module though, +to know when a new message has arrived, and trigger the update. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" + +#include "modules/TextMessageModule.h" + +namespace NicheGraphics::InkHUD +{ + +class Applet; + +class DMApplet : public Applet +{ + public: + void onRender() override; + + void onActivate() override; + void onDeactivate() override; + int onReceiveTextMessage(const meshtastic_MeshPacket *p); + + bool approveNotification(Notification &n) override; // Which notifications to suppress + + protected: + // Used to register our text message callback + CallbackObserver textMessageObserver = + CallbackObserver(this, &DMApplet::onReceiveTextMessage); +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp new file mode 100644 index 000000000..649089a91 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp @@ -0,0 +1,123 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "rtc.h" + +#include "gps/GeoCoord.h" + +#include "./HeardApplet.h" + +using namespace NicheGraphics; + +void InkHUD::HeardApplet::onActivate() +{ + // When applet begins, pre-fill with stale info from NodeDB + populateFromNodeDB(); +} + +void InkHUD::HeardApplet::onDeactivate() +{ + // Avoid an unlikely situation where frquent activation / deactivation populated duplicate info from node DB + cards.clear(); +} + +// When base applet hears a new packet, it extracts the info and passes it to us as CardInfo +// We need to store it (at front to sort recent), and request display update if our list has visibly changed as a result +void InkHUD::HeardApplet::handleParsed(CardInfo c) +{ + // Grab the previous entry. + // To check if the new data is different enough to justify re-render + // Need to cache now, before we manipulate the deque + CardInfo previous; + if (!cards.empty()) + previous = cards.at(0); + + // If we're updating an existing entry, remove the old one. Will reinsert at front + for (auto it = cards.begin(); it != cards.end(); ++it) { + if (it->nodeNum == c.nodeNum) { + cards.erase(it); + break; + } + } + + cards.push_front(c); // Insert into base class' card collection + cards.resize(min(maxCards(), (uint8_t)cards.size())); // Don't keep more cards than we could *ever* fit on screen + + // Our rendered image needs to change if: + if (previous.nodeNum != c.nodeNum // Different node + || previous.signal != c.signal // or different signal strength + || previous.distanceMeters != c.distanceMeters // or different position + || previous.hopsAway != c.hopsAway) // or different hops away + { + requestAutoshow(); + requestUpdate(); + } +} + +// When applet is activated, pre-fill with stale data from NodeDB +// We're sorting using the last_heard value. Succeptible to weirdness if node's RTC changes. +// No SNR is available in node db, so we can't calculate signal either +// These initial cards from node db will be gradually pushed out by new packets which originate from out base applet instead +void InkHUD::HeardApplet::populateFromNodeDB() +{ + // Fill a collection with pointers to each node in db + std::vector ordered; + for (auto mn = nodeDB->meshNodes->begin(); mn != nodeDB->meshNodes->end(); ++mn) { + // Only copy if valid, and not our own node + if (mn->num != 0 && mn->num != nodeDB->getNodeNum()) + ordered.push_back(&*mn); + } + + // Sort the collection by age + std::sort(ordered.begin(), ordered.end(), [](meshtastic_NodeInfoLite *top, meshtastic_NodeInfoLite *bottom) -> bool { + return (top->last_heard > bottom->last_heard); + }); + + // Keep the most recent entries onlyt + // Just enough to fill the screen + if (ordered.size() > maxCards()) + ordered.resize(maxCards()); + + // Create card info for these (stale) node observations + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + for (meshtastic_NodeInfoLite *node : ordered) { + CardInfo c; + c.nodeNum = node->num; + + if (node->has_hops_away) + c.hopsAway = node->hops_away; + + if (nodeDB->hasValidPosition(node) && nodeDB->hasValidPosition(ourNode)) { + // Get lat and long as float + // Meshtastic stores these as integers internally + float ourLat = ourNode->position.latitude_i * 1e-7; + float ourLong = ourNode->position.longitude_i * 1e-7; + float theirLat = node->position.latitude_i * 1e-7; + float theirLong = node->position.longitude_i * 1e-7; + + c.distanceMeters = (int32_t)GeoCoord::latLongToMeter(theirLat, theirLong, ourLat, ourLong); + } + + // Insert into the card collection (member of base class) + cards.push_back(c); + } +} + +// Text drawn in the usual applet header +// Handled by base class: ChronoListApplet +std::string InkHUD::HeardApplet::getHeaderText() +{ + uint16_t nodeCount = nodeDB->getNumMeshNodes() - 1; // Don't count our own node + + std::string text = "Heard: "; + + // Print node count, if nodeDB not yet nearing full + if (nodeCount < MAX_NUM_NODES) { + text += to_string(nodeCount); // Max nodes + text += " "; + text += (nodeCount == 1) ? "node" : "nodes"; + } + + return text; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h new file mode 100644 index 000000000..932b5a75e --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h @@ -0,0 +1,35 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Shows a list of all nodes (recently heard or not), sorted by time last heard. +Most of the work is done by the InkHUD::NodeListApplet base class + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.h" + +namespace NicheGraphics::InkHUD +{ + +class HeardApplet : public NodeListApplet +{ + public: + HeardApplet() : NodeListApplet("HeardApplet") {} + void onActivate() override; + void onDeactivate() override; + + protected: + void handleParsed(CardInfo c) override; // Store new info, and update display if needed + std::string getHeaderText() override; // Set title for this applet + + void populateFromNodeDB(); // Pre-fill the CardInfo collection from NodeDB +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.cpp new file mode 100644 index 000000000..88bed998d --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.cpp @@ -0,0 +1,110 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./PositionsApplet.h" + +using namespace NicheGraphics; + +void InkHUD::PositionsApplet::onRender() +{ + // Draw the usual map applet first + MapApplet::onRender(); + + // Draw our latest "node of interest" as a special marker + // ------------------------------------------------------- + // We might be rendering because we got a position packet from them + // We might be rendering because our own position updated + // Either way, we still highlight which node most recently sent us a position packet + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(lastFrom); + if (node && nodeDB->hasValidPosition(node) && enoughMarkers()) + drawLabeledMarker(node); +} + +// Determine if we need to redraw the map, when we receive a new position packet +ProcessMessage InkHUD::PositionsApplet::handleReceived(const meshtastic_MeshPacket &mp) +{ + // If applet is not active, we shouldn't be handling any data + // It's good practice for all applets to implement an early return like this + // for PositionsApplet, this is **required** - it's where we're handling active vs deactive + if (!isActive()) + return ProcessMessage::CONTINUE; + + // Try decode a position from the packet + bool hasPosition = false; + float lat; + float lng; + if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position position = meshtastic_Position_init_default; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &position)) { + if (position.has_latitude_i && position.has_longitude_i // Actually has position + && (position.latitude_i != 0 || position.longitude_i != 0)) // Position isn't "null island" + { + hasPosition = true; + lat = position.latitude_i * 1e-7; // Convert from Meshtastic's internal int32_t format + lng = position.longitude_i * 1e-7; + } + } + } + + // Skip if we didn't get a valid position + if (!hasPosition) + return ProcessMessage::CONTINUE; + + bool hasHopsAway = (mp.hop_start != 0 && mp.hop_limit <= mp.hop_start); // From NodeDB::updateFrom + uint8_t hopsAway = mp.hop_start - mp.hop_limit; + + // Determine if the position packet would change anything on-screen + // ----------------------------------------------------------------- + + bool somethingChanged = false; + + // If our own position + if (isFromUs(&mp)) { + // We get frequent position updates from connected phone + // Only update if we're travelled some distance, for rate limiting + // Todo: smarter detection of position changes + if (GeoCoord::latLongToMeter(ourLastLat, ourLastLng, lat, lng) > 50) { + somethingChanged = true; + ourLastLat = lat; + ourLastLng = lng; + } + } + + // If someone else's position + else { + // Check if this position is from someone different than our previous position packet + if (mp.from != lastFrom) { + somethingChanged = true; + lastFrom = mp.from; + lastLat = lat; + lastLng = lng; + lastHopsAway = hopsAway; + } + + // Same sender: check if position changed + // Todo: smarter detection of position changes + else if (GeoCoord::latLongToMeter(lastLat, lastLng, lat, lng) > 10) { + somethingChanged = true; + lastLat = lat; + lastLng = lng; + } + + // Same sender, same position: check if hops changed + // Only pay attention if the hopsAway value is valid + else if (hasHopsAway && (hopsAway != lastHopsAway)) { + somethingChanged = true; + lastHopsAway = hopsAway; + } + } + + // Decision reached + // ----------------- + + if (somethingChanged) { + requestAutoshow(); // Todo: only request this in some situations? + requestUpdate(); + } + + return ProcessMessage::CONTINUE; +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h new file mode 100644 index 000000000..5bcec339d --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h @@ -0,0 +1,43 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Plots position of all nodes from DB, with North facing up. +Scaled to fit the most distant node. +Size of cross represents hops away. +The node which has most recently sent a position will be labeled. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" + +#include "SinglePortModule.h" + +namespace NicheGraphics::InkHUD +{ + +class PositionsApplet : public MapApplet, public SinglePortModule +{ + public: + PositionsApplet() : SinglePortModule("PositionsApplet", meshtastic_PortNum_POSITION_APP) {} + void onRender() override; + + protected: + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + NodeNum lastFrom; // Sender of most recent (non-local) position packet + float lastLat; + float lastLng; + float lastHopsAway; + + float ourLastLat; // Info about the most recent (non-local) position packet + float ourLastLng; // Info about most recent *local* position +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp new file mode 100644 index 000000000..0378a0ee1 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp @@ -0,0 +1,150 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./RecentsListApplet.h" + +#include "rtc.h" + +using namespace NicheGraphics; + +InkHUD::RecentsListApplet::RecentsListApplet() : NodeListApplet("RecentsListApplet"), concurrency::OSThread("RecentsListApplet") +{ + // No scheduled tasks initially + OSThread::disable(); +} + +void InkHUD::RecentsListApplet::onActivate() +{ + // When the applet is activated, begin scheduled purging of any nodes which are no longer "active" + OSThread::enabled = true; + OSThread::setIntervalFromNow(60 * 1000UL); // Every minute +} + +void InkHUD::RecentsListApplet::onDeactivate() +{ + // Halt scheduled purging + OSThread::disable(); +} + +int32_t InkHUD::RecentsListApplet::runOnce() +{ + prune(); // Remove CardInfo and Age record for nodes which we haven't heard recently + return OSThread::interval; +} + +// When base applet hears a new packet, it extracts the info and passes it to us as CardInfo +// We need to store it (at front to sort recent), and request display update if our list has visibly changed as a result +// We also need to record the current time against the nodenum, so we know when it becomes inactive +void InkHUD::RecentsListApplet::handleParsed(CardInfo c) +{ + // Grab the previous entry. + // To check if the new data is different enough to justify re-render + // Need to cache now, before we manipulate the deque + CardInfo previous; + if (!cards.empty()) + previous = cards.at(0); + + // If we're updating an existing entry, remove the old one. Will reinsert at front + for (auto it = cards.begin(); it != cards.end(); ++it) { + if (it->nodeNum == c.nodeNum) { + cards.erase(it); + break; + } + } + + cards.push_front(c); // Store this CardInfo + cards.resize(min(maxCards(), (uint8_t)cards.size())); // Don't keep more cards than we could *ever* fit on screen + + // Record the time of this observation + // Used to count active nodes, and to know when to prune inactive nodes + seenNow(c.nodeNum); + + // Our rendered image needs to change if: + if (previous.nodeNum != c.nodeNum // Different node + || previous.signal != c.signal // or different signal strength + || previous.distanceMeters != c.distanceMeters // or different position + || previous.hopsAway != c.hopsAway) // or different hops away + { + prune(); // Take the opportunity now to remove inactive nodes + requestAutoshow(); + requestUpdate(); + } +} + +// Record the time (millis, right now) that we hear a node +// If we do not hear from a node for a while, its card and age info will be removed by the purge method, which runs regularly +void InkHUD::RecentsListApplet::seenNow(NodeNum nodeNum) +{ + // If we're updating an existing entry, remove the old one. Will reinsert at front + for (auto it = ages.begin(); it != ages.end(); ++it) { + if (it->nodeNum == nodeNum) { + ages.erase(it); + break; + } + } + + Age a; + a.nodeNum = nodeNum; + a.seenAtMs = millis(); + + ages.push_front(a); +} + +// Remove Card and Age info for any nodes which are now inactive +// Determined by when a node was last heard, in our internal record (not from nodeDB) +void InkHUD::RecentsListApplet::prune() +{ + // Iterate age records from newest to oldest + for (uint16_t i = 0; i < ages.size(); i++) { + // Found the first record which is too old + if (!isActive(ages.at(i).seenAtMs)) { + // Drop this item, and all others behind it + ages.resize(i); + cards.resize(i); + + // Request an update, if pruning did modify our data + // Required if pruning was scheduled. Redundent if pruning was prior to rendering. + requestAutoshow(); + requestUpdate(); + + break; + } + } + + // Push next scheduled pruning back + // Pruning may be called from by handleParsed, immediately prior to rendering + // In that case, we can slightly delay our scheduled pruning + OSThread::setIntervalFromNow(60 * 1000UL); +} + +// Is a timestamp old enough that it would make a node inactive, and in need of purging? +bool InkHUD::RecentsListApplet::isActive(uint32_t seenAtMs) +{ + uint32_t now = millis(); + uint32_t secsAgo = (now - seenAtMs) / 1000UL; // millis() overflow safe + + return (secsAgo < settings.recentlyActiveSeconds); +} + +// Text to be shown at top of applet +// ChronoListApplet base class allows us to set this dynamically +// Might want to adjust depending on node count, RTC status, etc +std::string InkHUD::RecentsListApplet::getHeaderText() +{ + std::string text; + + // Print the length of our "Recents" time-window + text += "Last "; + text += to_string(settings.recentlyActiveSeconds / 60); + text += " mins"; + + // Print the node count + const uint16_t nodeCount = ages.size(); + text += ": "; + text += to_string(nodeCount); + text += " "; + text += (nodeCount == 1) ? "node" : "nodes"; + + return text; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h new file mode 100644 index 000000000..74f5f3e57 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h @@ -0,0 +1,52 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Shows a list of nodes which have been recently active +The length of this "recently active" window is configurable using the onscreen menu + +Most of the work is done by the shared InkHUD::NodeListApplet base class + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.h" + +namespace NicheGraphics::InkHUD +{ + +class RecentsListApplet : public NodeListApplet, public concurrency::OSThread +{ + protected: + // Used internally to count the number of active nodes + // We count for ourselves, instead of using the value provided by NodeDB, + // as the values occasionally differ, due to the timing of our Applet's purge method + struct Age { + uint32_t nodeNum; + uint32_t seenAtMs; + }; + + public: + RecentsListApplet(); + void onActivate() override; + void onDeactivate() override; + + protected: + int32_t runOnce() override; + + void handleParsed(CardInfo c) override; // Store new info, update active count, update display if needed + std::string getHeaderText() override; // Set title for this applet + + void seenNow(NodeNum nodeNum); // Record that we have just seen this node, for active node count + void prune(); // Remove cards for nodes which we haven't seen recently + bool isActive(uint32_t seenAtMillis); // Is a node still active, based on when we last heard it? + + std::deque ages; // Information about when we last heard nodes. Independent of NodeDB +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp new file mode 100644 index 000000000..190f6088b --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp @@ -0,0 +1,270 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./ThreadedMessageApplet.h" + +#include "mesh/NodeDB.h" +#include "rtc.h" + +using namespace NicheGraphics; + +// Hard limits on how much message data to write to flash +// Avoid filling the storage if something goes wrong +// Normal usage should be well below this size +constexpr uint8_t MAX_MESSAGES_SAVED = 10; +constexpr uint32_t MAX_MESSAGE_SIZE = 250; + +InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex) : channelIndex(channelIndex) +{ + // Create the message store + // Will shortly attempt to load messages from RAM, if applet is active + // Label (filename in flash) is set from channel index + store = new MessageStore("ch" + to_string(channelIndex)); +} + +void InkHUD::ThreadedMessageApplet::onRender() +{ + setFont(fontSmall); + + // ============= + // Draw a header + // ============= + + // Header text + std::string headerText; + headerText += "Channel "; + headerText += to_string(channelIndex); + headerText += ": "; + if (channels.isDefaultChannel(channelIndex)) + headerText += "Public"; + else + headerText += channels.getByIndex(channelIndex).settings.name; + + // Draw a "standard" applet header + drawHeader(headerText); + + // Y position for divider + const int16_t dividerY = Applet::getHeaderHeight() - 1; + + // ================== + // Draw each message + // ================== + + // Restrict drawing area + // - don't overdraw the header + // - small gap below divider + setCrop(0, dividerY + 2, width(), height() - (dividerY + 2)); + + // Set padding + // - separates text from the vertical line which marks its edge + constexpr uint16_t padW = 2; + constexpr int16_t msgL = padW; + const int16_t msgR = (width() - 1) - padW; + const uint16_t msgW = (msgR - msgL) + 1; + + int16_t msgB = height() - 1; // Vertical cursor for drawing. Messages are bottom-aligned to this value. + uint8_t i = 0; // Index of stored message + + // Loop over messages + // - until no messages left, or + // - until no part of message fits on screen + while (msgB >= (0 - fontSmall.lineHeight()) && i < store->messages.size()) { + + // Grab data for message + MessageStore::Message &m = store->messages.at(i); + bool outgoing = (m.sender == 0); + meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(m.sender); + + // Cache bottom Y of message text + // - Used when drawing vertical line alongside + const int16_t dotsB = msgB; + + // Get dimensions for message text + uint16_t bodyH = getWrappedTextHeight(msgL, msgW, m.text); + int16_t bodyT = msgB - bodyH; + + // Print message + // - if incoming + if (!outgoing) + printWrapped(msgL, bodyT, msgW, m.text); + + // Print message + // - if outgoing + else { + if (getTextWidth(m.text) < width()) // If short, + printAt(msgR, bodyT, m.text, RIGHT); // print right align + else // If long, + printWrapped(msgL, bodyT, msgW, m.text); // need printWrapped(), which doesn't support right align + } + + // Move cursor up + // - above message text + msgB -= bodyH; + msgB -= getFont().lineHeight() * 0.2; // Padding between message and header + + // Compose info string + // - shortname, if possible, or "me" + // - time received, if possible + std::string info; + if (sender && sender->has_user) + info += sender->user.short_name; + else if (outgoing) + info += "Me"; + else + info += hexifyNodeNum(m.sender); + + std::string timeString = getTimeString(m.timestamp); + if (timeString.length() > 0) { + info += " - "; + info += timeString; + } + + // Print the info string + // - Faux bold: printed twice, shifted horizontally by one px + printAt(outgoing ? msgR : msgL, msgB, info, outgoing ? RIGHT : LEFT, BOTTOM); + printAt(outgoing ? msgR - 1 : msgL + 1, msgB, info, outgoing ? RIGHT : LEFT, BOTTOM); + + // Underline the info string + const int16_t divY = msgB; + int16_t divL; + int16_t divR; + if (!outgoing) { + // Left side - incoming + divL = msgL; + divR = getTextWidth(info) + getFont().lineHeight() / 2; + } else { + // Right side - outgoing + divR = msgR; + divL = divR - getTextWidth(info) - getFont().lineHeight() / 2; + } + for (int16_t x = divL; x <= divR; x += 2) + drawPixel(x, divY, BLACK); + + // Move cursor up: above info string + msgB -= fontSmall.lineHeight(); + + // Vertical line alongside message + for (int16_t y = msgB; y < dotsB; y += 1) + drawPixel(outgoing ? width() - 1 : 0, y, BLACK); + + // Move cursor up: padding before next message + msgB -= fontSmall.lineHeight() * 0.5; + + i++; + } // End of loop: drawing each message + + // Fade effect: + // Area immediately below the divider. Overdraw with sparse white lines. + // Make text appear to pass behind the header + hatchRegion(0, dividerY + 1, width(), fontSmall.lineHeight() / 3, 2, WHITE); + + // If we've run out of screen to draw messages, we can drop any leftover data from the queue + // Those messages have been pushed off the screen-top by newer ones + while (i < store->messages.size()) + store->messages.pop_back(); +} + +// Code which runs when the applet begins running +// This might happen at boot, or if user enables the applet at run-time, via the menu +void InkHUD::ThreadedMessageApplet::onActivate() +{ + loadMessagesFromFlash(); + textMessageObserver.observe(textMessageModule); // Begin handling any new text messages with onReceiveTextMessage +} + +// Code which runs when the applet stop running +// This might be happen at shutdown, or if user disables the applet at run-time +void InkHUD::ThreadedMessageApplet::onDeactivate() +{ + textMessageObserver.unobserve(textMessageModule); // Stop handling any new text messages with onReceiveTextMessage +} + +// Handle new text messages +// These might be incoming, from the mesh, or outgoing from phone +// Each instance of the ThreadMessageApplet will only listen on one specific channel +// Method should return 0, to indicate general success to TextMessageModule +int InkHUD::ThreadedMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) +{ + // Abort if applet fully deactivated + // Already handled by onActivate and onDeactivate, but good practice for all applets + if (!isActive()) + return 0; + + // Abort if wrong channel + if (p->channel != this->channelIndex) + return 0; + + // Abort if message was a DM + if (p->to != NODENUM_BROADCAST) + return 0; + + // Abort if messages was an "emoji reaction" + // Possibly some implemetation of this in future? + if (p->decoded.emoji) + return 0; + + // Extract info into our slimmed-down "StoredMessage" type + MessageStore::Message newMessage; + newMessage.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time + newMessage.sender = p->from; + newMessage.channelIndex = p->channel; + newMessage.text = std::string(&p->decoded.payload.bytes[0], &p->decoded.payload.bytes[p->decoded.payload.size]); + + // Store newest message at front + // These records are used when rendering, and also stored in flash at shutdown + store->messages.push_front(newMessage); + + // If this was an incoming message, suggest that our applet becomes foreground, if permitted + if (getFrom(p) != nodeDB->getNodeNum()) + requestAutoshow(); + + // Redraw the applet, perhaps. + requestUpdate(); // Want to update display, if applet is foreground + + return 0; +} + +// Don't show notifications for text messages broadcast to our channel, when the applet is displayed +bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n) +{ + if (n.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST && n.getChannel() == channelIndex) + return false; + + // None of our business. Allow the notification. + else + return true; +} + +// Save several recent messages to flash +// Stores the contents of ThreadedMessageApplet::messages +// Just enough messages to fill the display +// Messages are packed "back-to-back", to minimize blocks of flash used +void InkHUD::ThreadedMessageApplet::saveMessagesToFlash() +{ + // Create a label (will become the filename in flash) + std::string label = "ch" + to_string(channelIndex); + + store->saveToFlash(); +} + +// Load recent messages to flash +// Fills ThreadedMessageApplet::messages with previous messages +// Just enough messages have been stored to cover the display +void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash() +{ + // Create a label (will become the filename in flash) + std::string label = "ch" + to_string(channelIndex); + + store->loadFromFlash(); +} + +// Code to run when device is shutting down +// This is in addition to any onDeactivate() code, which will also run +// Todo: implement before a reboot also +void InkHUD::ThreadedMessageApplet::onShutdown() +{ + // Save our current set of messages to flash, provided the applet isn't disabled + if (isActive()) + saveMessagesToFlash(); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h new file mode 100644 index 000000000..5bb8bf96e --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h @@ -0,0 +1,63 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Displays a thread-view of incoming and outgoing message for a specific channel + +The channel for this applet is set in the constructor, +when the applet is added to WindowManager in the setupNicheGraphics method. + +Several messages are saved to flash at shutdown, to preseve applet between reboots. +This class has its own internal method for saving and loading to fs, which interacts directly with the FSCommon layer. +If the amount of flash usage is unacceptable, we could keep these in RAM only. + +Multiple instances of this channel may be used. This must be done at buildtime. +Suggest a max of two channel, to minimize fs usage? + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/InkHUD/Applet.h" +#include "graphics/niche/InkHUD/MessageStore.h" + +#include "modules/TextMessageModule.h" + +namespace NicheGraphics::InkHUD +{ + +class Applet; + +class ThreadedMessageApplet : public Applet +{ + public: + ThreadedMessageApplet(uint8_t channelIndex); + ThreadedMessageApplet() = delete; + + void onRender() override; + + void onActivate() override; + void onDeactivate() override; + void onShutdown() override; + int onReceiveTextMessage(const meshtastic_MeshPacket *p); + + bool approveNotification(Notification &n) override; // Which notifications to suppress + + protected: + // Used to register our text message callback + CallbackObserver textMessageObserver = + CallbackObserver(this, + &ThreadedMessageApplet::onReceiveTextMessage); + + void saveMessagesToFlash(); + void loadMessagesFromFlash(); + + MessageStore *store; // Messages, held in RAM for use, ready to save to flash on shutdown + uint8_t channelIndex = 0; +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/MessageStore.cpp b/src/graphics/niche/InkHUD/MessageStore.cpp new file mode 100644 index 000000000..ac6fe1b35 --- /dev/null +++ b/src/graphics/niche/InkHUD/MessageStore.cpp @@ -0,0 +1,139 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./MessageStore.h" + +#include "SafeFile.h" + +using namespace NicheGraphics; + +// Hard limits on how much message data to write to flash +// Avoid filling the storage if something goes wrong +// Normal usage should be well below this size +constexpr uint8_t MAX_MESSAGES_SAVED = 10; +constexpr uint32_t MAX_MESSAGE_SIZE = 250; + +InkHUD::MessageStore::MessageStore(std::string label) +{ + filename = ""; + filename += "/NicheGraphics"; + filename += "/"; + filename += label; + filename += ".msgs"; +} + +// Write the contents of the MessageStore::messages object to flash +void InkHUD::MessageStore::saveToFlash() +{ + assert(!filename.empty()); + +#ifdef FSCom + // Make the directory, if doesn't already exist + // This is the same directory accessed by NicheGraphics::FlashData + FSCom.mkdir("/NicheGraphics"); + + // Open or create the file + // No "full atomic": don't save then rename + auto f = SafeFile(filename.c_str(), false); + + LOG_INFO("Saving messages in %s", filename.c_str()); + + // 1st byte: how many messages will be written to store + f.write(messages.size()); + + // For each message + for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) { + Message &m = messages.at(i); + f.write((uint8_t *)&m.timestamp, sizeof(m.timestamp)); // Write timestamp. 4 bytes + f.write((uint8_t *)&m.sender, sizeof(m.sender)); // Write sender NodeId. 4 Bytes + f.write((uint8_t *)&m.channelIndex, sizeof(m.channelIndex)); // Write channel index. 1 Byte + f.write((uint8_t *)m.text.c_str(), min(MAX_MESSAGE_SIZE, m.text.size())); // Write message text. Variable length + f.write('\0'); // Append null term + LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", (uint32_t)i, min(MAX_MESSAGE_SIZE, m.text.size()), m.text.c_str()); + } + + bool writeSucceeded = f.close(); + + if (!writeSucceeded) { + LOG_ERROR("Can't write data!"); + } +#else + LOG_ERROR("ERROR: Filesystem not implemented\n"); +#endif +} + +// Attempt to load the previous contents of the MessageStore:message deque from flash. +// Filename is controlled by the "label" parameter +void InkHUD::MessageStore::loadFromFlash() +{ + // Hopefully redundant. Initial intention is to only load / save once per boot. + messages.clear(); + +#ifdef FSCom + + // Check that the file *does* actually exist + if (!FSCom.exists(filename.c_str())) { + LOG_WARN("'%s' not found. Using default values", filename.c_str()); + return; + } + + // Check that the file *does* actually exist + if (!FSCom.exists(filename.c_str())) { + LOG_INFO("'%s' not found.", filename.c_str()); + return; + } + + // Open the file + auto f = FSCom.open(filename.c_str(), FILE_O_READ); + + if (f.size() == 0) { + LOG_INFO("%s is empty", filename.c_str()); + f.close(); + return; + } + + // If opened, start reading + if (f) { + LOG_INFO("Loading threaded messages '%s'", filename.c_str()); + + // First byte: how many messages are in the flash store + uint8_t flashMessageCount = 0; + f.readBytes((char *)&flashMessageCount, 1); + LOG_DEBUG("Messages available: %u", (uint32_t)flashMessageCount); + + // For each message + for (uint8_t i = 0; i < flashMessageCount && i < MAX_MESSAGES_SAVED; i++) { + Message m; + + // Read meta data (fixed width) + f.readBytes((char *)&m.timestamp, sizeof(m.timestamp)); + f.readBytes((char *)&m.sender, sizeof(m.sender)); + f.readBytes((char *)&m.channelIndex, sizeof(m.channelIndex)); + + // Read characters until we find a null term + char c; + while (m.text.size() < MAX_MESSAGE_SIZE) { + f.readBytes(&c, 1); + if (c != '\0') + m.text += c; + else + break; + } + + // Store in RAM + messages.push_back(m); + + LOG_DEBUG("#%u, timestamp=%u, sender(num)=%u, text=\"%s\"", (uint32_t)i, m.timestamp, m.sender, m.text.c_str()); + } + + f.close(); + } else { + LOG_ERROR("Could not open / read %s", filename.c_str()); + } +#else + LOG_ERROR("Filesystem not implemented"); + state = LoadFileState::NO_FILESYSTEM; +#endif + return; +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/MessageStore.h b/src/graphics/niche/InkHUD/MessageStore.h new file mode 100644 index 000000000..3cf7d0f68 --- /dev/null +++ b/src/graphics/niche/InkHUD/MessageStore.h @@ -0,0 +1,47 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +We hold a few recent messages, for features like the threaded message applet. +This class contains a struct for storing those messages, +and methods for serializing them to flash. + +*/ + +#pragma once + +#include "configuration.h" + +#include + +#include "mesh/MeshTypes.h" + +namespace NicheGraphics::InkHUD +{ + +class MessageStore +{ + public: + // A stored message + struct Message { + uint32_t timestamp; // Epoch seconds + NodeNum sender = 0; + uint8_t channelIndex; + std::string text; + }; + + MessageStore() = delete; + MessageStore(std::string label); // Label determines filename in flash + + void saveToFlash(); + void loadFromFlash(); + + std::deque messages; // Interact with this object! + + private: + std::string filename; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Persistence.cpp b/src/graphics/niche/InkHUD/Persistence.cpp new file mode 100644 index 000000000..6e8ac0458 --- /dev/null +++ b/src/graphics/niche/InkHUD/Persistence.cpp @@ -0,0 +1,59 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./Persistence.h" + +using namespace NicheGraphics; + +// Load settings and latestMessage data +void InkHUD::loadDataFromFlash() +{ + // Load the InkHUD settings from flash, and check version number + // We should only consider the version number if the InkHUD flashdata component reports that we *did* actually load flash data + InkHUD::Settings loadedSettings; + bool loadSucceeded = FlashData::load(&loadedSettings, "settings"); + if (loadSucceeded && loadedSettings.meta.version == SETTINGS_VERSION && loadedSettings.meta.version != 0) + settings = loadedSettings; // Version matched, replace the defaults with the loaded values + else + LOG_WARN("Settings version changed. Using defaults"); + + // Load previous "latestMessages" data from flash + MessageStore store("latest"); + store.loadFromFlash(); + + // Place into latestMessage struct, for convenient access + // Number of strings loaded determines whether last message was broadcast or dm + if (store.messages.size() == 1) { + latestMessage.dm = store.messages.at(0); + latestMessage.wasBroadcast = false; + } else if (store.messages.size() == 2) { + latestMessage.dm = store.messages.at(0); + latestMessage.broadcast = store.messages.at(1); + latestMessage.wasBroadcast = true; + } +} + +// Save settings and latestMessage data +void InkHUD::saveDataToFlash() +{ + // Save the InkHUD settings to flash + FlashData::save(&settings, "settings"); + + // Number of strings saved determines whether last message was broadcast or dm + MessageStore store("latest"); + store.messages.push_back(latestMessage.dm); + if (latestMessage.wasBroadcast) + store.messages.push_back(latestMessage.broadcast); + store.saveToFlash(); +} + +// Holds InkHUD settings while running +// Saved back to Flash at shutdown +// Accessed by including persistence.h +InkHUD::Settings InkHUD::settings; + +// Holds copies of the most recent broadcast and DM messages while running +// Saved to Flash at shutdown +// Accessed by including persistence.h +InkHUD::LatestMessage InkHUD::latestMessage; + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Persistence.h b/src/graphics/niche/InkHUD/Persistence.h new file mode 100644 index 000000000..e2daa02d9 --- /dev/null +++ b/src/graphics/niche/InkHUD/Persistence.h @@ -0,0 +1,123 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +A quick and dirty alternative to storing "device only" settings using the protobufs +Convenient during development. +Potentially a polite option, to avoid polluting the generated code with values for obscure use cases like this. + +The save / load mechanism is a shared NicheGraphics feature. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/FlashData.h" +#include "graphics/niche/InkHUD/MessageStore.h" + +namespace NicheGraphics::InkHUD +{ + +constexpr uint8_t MAX_TILES_GLOBAL = 4; +constexpr uint8_t MAX_USERAPPLETS_GLOBAL = 16; + +// Used to invalidate old settings, if needed +// Version 0 is reserved for testing, and will always load defaults +constexpr uint32_t SETTINGS_VERSION = 2; + +struct Settings { + struct Meta { + // Used to invalidate old savefiles, if we make breaking changes + uint32_t version = SETTINGS_VERSION; + } meta; + + struct UserTiles { + // How many tiles are shown + uint8_t count = 1; + + // Maximum amount of tiles for this display + uint8_t maxCount = 4; + + // Which tile is focused (responding to user button input) + uint8_t focused = 0; + + // Which applet is displayed on which tile + // Index of array: which tile, as indexed in WindowManager::tiles + // Value of array: which applet, as indexed in WindowManager::activeApplets + uint8_t displayedUserApplet[MAX_TILES_GLOBAL] = {0, 1, 2, 3}; + } userTiles; + + struct UserApplets { + // Which applets are running (either displayed, or in the background) + // Index of array: which applet, as indexed in WindowManager::applets + // Initial value is set by the "activeByDefault" parameter of WindowManager::addApplet, in setupNicheGraphics() + bool active[MAX_USERAPPLETS_GLOBAL]; + + // Which user applets should be automatically shown when they have important data to show + // If none set, foreground applets should remain foreground without manual user input + // If multiple applets request this at once, + // priority is the order which they were passed to WindowManager::addApplets, in setupNicheGraphics() + bool autoshow[MAX_USERAPPLETS_GLOBAL]{false}; + } userApplets; + + // Features which the use can enable / disable via the on-screen menu + struct OptionalFeatures { + bool notifications = true; + bool batteryIcon = false; + } optionalFeatures; + + // Some menu items may not be required, based on device / configuration + // We can enable them only when needed, to de-clutter the menu + struct OptionalMenuItems { + // If aux button is used to swap between tiles, we have to need for this menu item + bool nextTile = true; + + // Used if backlight present, and not controlled by AUX button + // If this item is added to menu: backlight is always active when menu is open + // The added menu items then allows the user to "Keep Backlight On", globally. + bool backlight = false; + } optionalMenuItems; + + // Allows tips to be run once only + struct Tips { + // Enables the longer "tutorial" shown only on first boot + // Once tutorial has been completed, it is no longer shown + bool firstBoot = true; + + // User is advised to shutdown before removing device power + // Once user executes a shutdown (either via menu or client app), + // this tip is no longer shown + bool safeShutdownSeen = false; + } tips; + + // Rotation of the display + // Multiples of 90 degrees clockwise + // Most commonly: rotation is 0 when flex connector is oriented below display + uint8_t rotation = 1; + + // How long do we consider another node to be "active"? + // Used when applets want to filter for "active nodes" only + uint32_t recentlyActiveSeconds = 2 * 60; +}; + +// Most recently received text message +// Value is updated by InkHUD::WindowManager, as a courtesty to applets +// Note: different from devicestate.rx_text_message, +// which may contain an *outgoing message* to broadcast +struct LatestMessage { + MessageStore::Message broadcast; // Most recent message received broadcast + MessageStore::Message dm; // Most recent received DM + bool wasBroadcast; // True if most recent broadcast is newer than most recent dm +}; + +extern Settings settings; +extern LatestMessage latestMessage; + +void loadDataFromFlash(); +void saveDataToFlash(); + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/PlatformioConfig.ini b/src/graphics/niche/InkHUD/PlatformioConfig.ini new file mode 100644 index 000000000..7eb1d34e9 --- /dev/null +++ b/src/graphics/niche/InkHUD/PlatformioConfig.ini @@ -0,0 +1,10 @@ +[inkhud] +board_level = extra +build_src_filter = +<../variants/$PIOENV> ; Include nicheGraphics.h +build_flags = + -D MESHTASTIC_INCLUDE_NICHE_GRAPHICS ; Use NicheGraphics + -D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD (a NicheGraphics UI) + -D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class + -D HAS_BUTTON=0 ; Suppress default ButtonThread +lib_deps = + https://github.com/ZinggJM/GFX_Root#2.0.0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/README.md b/src/graphics/niche/InkHUD/README.md new file mode 100644 index 000000000..8d788ffa8 --- /dev/null +++ b/src/graphics/niche/InkHUD/README.md @@ -0,0 +1,12 @@ +# InkHUD + +A heads-up-display for E-Ink devices, intended to supplement a connected phone / client. Implemented as a "NicheGraphics" UI. + +Supported devices (as of 1st Feb. 2025): + +- Heltec Vision Master E213 +- Heltec Vision Master E290 +- Heltec Wireless Paper V1.1 +- LILYGO T-Echo + +More to follow diff --git a/src/graphics/niche/InkHUD/Tile.cpp b/src/graphics/niche/InkHUD/Tile.cpp new file mode 100644 index 000000000..e65835801 --- /dev/null +++ b/src/graphics/niche/InkHUD/Tile.cpp @@ -0,0 +1,237 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./Tile.h" + +#include "concurrency/Periodic.h" + +using namespace NicheGraphics; + +// Static members of Tile class (for linking) +InkHUD::Tile *InkHUD::Tile::highlightTarget; +bool InkHUD::Tile::highlightShown; + +// For dismissing the highlight indicator, after a few seconds +// Highlighting is used to inform user of which tile is now focused +static concurrency::Periodic *taskHighlight; +static int32_t runtaskHighlight() +{ + LOG_DEBUG("Dismissing Highlight"); + InkHUD::Tile::highlightShown = false; + InkHUD::Tile::highlightTarget = nullptr; + InkHUD::WindowManager::getInstance()->forceUpdate(Drivers::EInk::UpdateTypes::FAST); // Re-render, clearing the highlighting + return taskHighlight->disable(); +} +static void inittaskHighlight() +{ + static bool doneOnce = false; + if (!doneOnce) { + taskHighlight = new concurrency::Periodic("Highlight", runtaskHighlight); + taskHighlight->disable(); + doneOnce = true; + } +} + +InkHUD::Tile::Tile() +{ + // For convenince + windowManager = InkHUD::WindowManager::getInstance(); + + inittaskHighlight(); + Tile::highlightTarget = nullptr; + Tile::highlightShown = false; +} + +// Set the region of the tile automatically, based on the user's chosen layout +// This method places tiles which will host user applets +// The WindowManager multiplexes the applets to these tiles automatically +void InkHUD::Tile::placeUserTile(uint8_t userTileCount, uint8_t tileIndex) +{ + uint16_t displayWidth = windowManager->getWidth(); + uint16_t displayHeight = windowManager->getHeight(); + + bool landscape = displayWidth > displayHeight; + + // Check for any stray tiles + if (tileIndex > (userTileCount - 1)) { + // Dummy values to prevent rendering + LOG_WARN("Tile index out of bounds"); + left = -2; + top = -2; + width = 1; + height = 1; + return; + } + + // Todo: special handling for the notification area + // Todo: special handling for 3 tile layout + + // Gap between tiles + const uint16_t spacing = 4; + + switch (userTileCount) { + // One tile only + case 1: + left = 0; + top = 0; + width = displayWidth; + height = displayHeight; + break; + + // Two tiles + case 2: + if (landscape) { + // Side by side + left = ((displayWidth / 2) + (spacing / 2)) * tileIndex; + top = 0; + width = (displayWidth / 2) - (spacing / 2); + height = displayHeight; + } else { + // Above and below + left = 0; + top = 0 + (((displayHeight / 2) + (spacing / 2)) * tileIndex); + width = displayWidth; + height = (displayHeight / 2) - (spacing / 2); + } + break; + + // Four tiles + case 4: + width = (displayWidth / 2) - (spacing / 2); + height = (displayHeight / 2) - (spacing / 2); + switch (tileIndex) { + case 0: + left = 0; + top = 0; + break; + case 1: + left = 0 + (width - 1) + spacing; + top = 0; + break; + case 2: + left = 0; + top = 0 + (height - 1) + spacing; + break; + case 3: + left = 0 + (width - 1) + spacing; + top = 0 + (height - 1) + spacing; + break; + } + break; + + default: + LOG_ERROR("Unsupported tile layout"); + assert(0); + } + + assert(width > 0 && height > 0); + + this->left = left; + this->top = top; + this->width = width; + this->height = height; +} + +// Manually set the region for a tile +// This is only done for tiles which will host certain "System Applets", which have unique position / sizes: +// Things like the NotificationApplet, BatteryIconApplet, etc +void InkHUD::Tile::placeSystemTile(int16_t left, int16_t top, uint16_t width, uint16_t height) +{ + assert(width > 0 && height > 0); + + this->left = left; + this->top = top; + this->width = width; + this->height = height; +} + +// Place an applet onto a tile +// Creates a reciprocal link between applet and tile +// The tile should always know which applet is displayed +// The applet should always know which tile it is display on +// This is enforced with asserts +// Assigning a new applet will break a previous link +// Link may also be broken by assigning a nullptr +void InkHUD::Tile::assignApplet(Applet *a) +{ + // Break the link between old applet and this tile + if (assignedApplet) + assignedApplet->setTile(nullptr); + + // Store the new applet + assignedApplet = a; + + // Create the reciprocal link between the new applet and this tile + if (a) + a->setTile(this); +} + +// Get pointer to whichever applet is displayed on this tile +InkHUD::Applet *InkHUD::Tile::getAssignedApplet() +{ + return assignedApplet; +} + +// Receive drawing output from the assigned applet, +// and translate it from "applet-space" coordinates, to it's true location. +// The final "rotation" step is performed by the windowManager +void InkHUD::Tile::handleAppletPixel(int16_t x, int16_t y, Color c) +{ + // Move pixels from applet-space to tile-space + x += left; + y += top; + + // Crop to tile borders + if (x >= left && x < (left + width) && y >= top && y < (top + height)) { + // Pass to the window manager + windowManager->handleTilePixel(x, y, c); + } +} + +// Called by Applet base class, when learning of its dimensions +uint16_t InkHUD::Tile::getWidth() +{ + return width; +} + +// Called by Applet base class, when learning of its dimensions +uint16_t InkHUD::Tile::getHeight() +{ + return height; +} + +// Longest edge of the display, in pixels +// Maximum possible size of any tile's width / height +// Used by some components to allocate resources for the "worst possible situtation" +// "Sizing the cathedral for christmas eve" +uint16_t InkHUD::Tile::maxDisplayDimension() +{ + WindowManager *wm = WindowManager::getInstance(); + return max(wm->getHeight(), wm->getWidth()); +} + +// Ask for this tile to be highlighted +// Used to indicate which tile is now indicated after focus changes +// Only used for aux button focus changes, not changes via menu +void InkHUD::Tile::requestHighlight() +{ + Tile::highlightTarget = this; + Tile::highlightShown = false; + windowManager->forceUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +// Starts the timer which will automatically dismiss the highlighting, if the tile doesn't organically redraw first +void InkHUD::Tile::startHighlightTimeout() +{ + taskHighlight->setIntervalFromNow(5 * 1000UL); + taskHighlight->enabled = true; +} + +// Stop the timer which would automatically dismiss the highlighting +// Called if the tile organically renders before the timer is up +void InkHUD::Tile::cancelHighlightTimeout() +{ + if (taskHighlight->enabled) + taskHighlight->disable(); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Tile.h b/src/graphics/niche/InkHUD/Tile.h new file mode 100644 index 000000000..e41536e53 --- /dev/null +++ b/src/graphics/niche/InkHUD/Tile.h @@ -0,0 +1,62 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Class which represents a region of the display area + Applets are assigned to a tile + Tile controls the Applet's dimensions + Tile receives pixel output from the applet, and translates it to the correct display region + +*/ + +#pragma once + +#include "configuration.h" + +#include "./Applet.h" +#include "./Types.h" +#include "./WindowManager.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +class Applet; +class WindowManager; + +class Tile +{ + public: + Tile(); + void placeUserTile(uint8_t layoutSize, uint8_t tileIndex); // Assign region automatically, based on layout + void placeSystemTile(int16_t left, int16_t top, uint16_t width, uint16_t height); // Assign region manually + void handleAppletPixel(int16_t x, int16_t y, Color c); // Receive px output from assigned applet + uint16_t getWidth(); // Used to set the assigned applet's width before render + uint16_t getHeight(); // Used to set the assigned applet's height before render + static uint16_t maxDisplayDimension(); // Largest possible width / height any tile may ever encounter + + void assignApplet(Applet *a); // Place an applet onto a tile + Applet *getAssignedApplet(); // Applet which is on a tile + + void requestHighlight(); // Ask for this tile to be highlighted + static void startHighlightTimeout(); // Start the auto-dismissal timer + static void cancelHighlightTimeout(); // Cancel the auto-dismissal timer early; already dismissed + + static Tile *highlightTarget; // Which tile are we highlighting? (Intending to highlight?) + static bool highlightShown; // Is the tile highlighted yet? Controlls highlight vs dismiss + + protected: + int16_t left; + int16_t top; + uint16_t width; + uint16_t height; + + Applet *assignedApplet = nullptr; // Pointer to the applet which is currently linked with the tile + + WindowManager *windowManager; // Convenient access to the WindowManager singleton +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/Types.h b/src/graphics/niche/InkHUD/Types.h new file mode 100644 index 000000000..f4ab9ed4e --- /dev/null +++ b/src/graphics/niche/InkHUD/Types.h @@ -0,0 +1,62 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Custom data types for InkHUD + +Only "general purpose" data-types should be defined here. +If your applet has its own structs or enums, which won't be useful to other applets, +please define them inside (or in the same folder as) your applet. + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/Drivers/EInk/EInk.h" + +namespace NicheGraphics::InkHUD +{ + +// Color, understood by display controller IC (as bit values) +// Also suitable for use as AdafruitGFX colors +enum Color : uint8_t { + BLACK = 0, + WHITE = 1, +}; + +// Info contained within AppletFont +struct FontDimensions { + uint8_t height; + uint8_t ascenderHeight; + uint8_t descenderHeight; +}; + +// Which edge Applet::printAt will place on the X parameter +enum HorizontalAlignment : uint8_t { + LEFT, + RIGHT, + CENTER, +}; + +// Which edge Applet::printAt will place on the Y parameter +enum VerticalAlignment : uint8_t { + TOP, + MIDDLE, + BOTTOM, +}; + +// An easy-to-understand intepretation of SNR and RSSI +// Calculate with Applet::getSignalStringth +enum SignalStrength : int8_t { + SIGNAL_UNKNOWN = -1, + SIGNAL_NONE, + SIGNAL_BAD, + SIGNAL_FAIR, + SIGNAL_GOOD, +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/UpdateMediator.cpp b/src/graphics/niche/InkHUD/UpdateMediator.cpp new file mode 100644 index 000000000..16fc21cef --- /dev/null +++ b/src/graphics/niche/InkHUD/UpdateMediator.cpp @@ -0,0 +1,151 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./UpdateMediator.h" + +#include "./WindowManager.h" + +using namespace NicheGraphics; + +static constexpr uint32_t MAINTENANCE_MS_INITIAL = 60 * 1000UL; +static constexpr uint32_t MAINTENANCE_MS = 60 * 60 * 1000UL; + +InkHUD::UpdateMediator::UpdateMediator() : concurrency::OSThread("Mediator") +{ + // Timer disabled by default + OSThread::disable(); +} + +// Ask which type of update operation we should perform +// Even if we explicitly want a FAST or FULL update, we should pass it through this method, +// as it allows UpdateMediator to count the refreshes. +// Internal "maintenance" refreshes are not passed through evaluate, however. +Drivers::EInk::UpdateTypes InkHUD::UpdateMediator::evaluate(Drivers::EInk::UpdateTypes requested) +{ + LOG_DEBUG("FULL-update debt:%f", debt); + + // For conveninece + typedef Drivers::EInk::UpdateTypes UpdateTypes; + + // Check whether we've paid off enough debt to stop unprovoked refreshing (if in progress) + // This maintenance behavior will also halt itself when the timer next fires, + // but that could be an hour away, so we can stop it early here and free up resources + if (OSThread::enabled && debt == 0.0) + endMaintenance(); + + // Explicitly requested FULL + if (requested == UpdateTypes::FULL) { + LOG_DEBUG("Explicit FULL"); + debt = max(debt - 1.0, 0.0); // Record that we have paid back (some of) the FULL refresh debt + return UpdateTypes::FULL; + } + + // Explicitly requested FAST + if (requested == UpdateTypes::FAST) { + LOG_DEBUG("Explicit FAST"); + // Add to the FULL refresh debt + if (debt < 1.0) + debt += 1.0 / fastPerFull; + else + debt += stressMultiplier * (1.0 / fastPerFull); // More debt if too many consecutive FAST refreshes + + // If *significant debt*, begin occasionally refreshing *unprovoked* + // This maintenance behavior is only triggered here, during periods of user interaction + if (debt >= 2.0) + beginMaintenance(); + + return UpdateTypes::FAST; // Give them what the asked for + } + + // Handling UpdateTypes::UNSPECIFIED + // ----------------------------------- + // In this case, the UI doesn't care which refresh we use + + // Not much debt: suggest FAST + if (debt < 1.0) { + LOG_DEBUG("UNSPECIFIED: using FAST"); + debt += 1.0 / fastPerFull; + return UpdateTypes::FAST; + } + + // In debt: suggest FULL + else { + LOG_DEBUG("UNSPECIFIED: using FULL"); + debt = max(debt - 1.0, 0.0); // Record that we have paid back (some of) the FULL refresh debt + + // When maintenance begins, the first refresh happens shortly after user interaction ceases (a minute or so) + // If we *are* given an opportunity to refresh before that, we'll skip that initial maintenance refresh + // We were intending to use that initial refresh to redraw the screen as FULL, but we're doing that now, organically + if (OSThread::enabled && OSThread::interval == MAINTENANCE_MS_INITIAL) { + LOG_DEBUG("Initial maintenance skipped"); + OSThread::setInterval(MAINTENANCE_MS); // Note: not intervalFromNow + } + + return UpdateTypes::FULL; + } +} + +// Determine which of two update types is more important to honor +// Explicit FAST is more important than UNSPECIFIED - prioritize responsiveness +// Explicit FULL is more important than explicint FAST - prioritize image quality: explicit FULL is rare +Drivers::EInk::UpdateTypes InkHUD::UpdateMediator::prioritize(Drivers::EInk::UpdateTypes type1, Drivers::EInk::UpdateTypes type2) +{ + switch (type1) { + case Drivers::EInk::UpdateTypes::UNSPECIFIED: + return type2; + + case Drivers::EInk::UpdateTypes::FAST: + return (type2 == Drivers::EInk::UpdateTypes::FULL) ? Drivers::EInk::UpdateTypes::FULL : Drivers::EInk::UpdateTypes::FAST; + + case Drivers::EInk::UpdateTypes::FULL: + return type1; + } + + return Drivers::EInk::UpdateTypes::UNSPECIFIED; // Suppress compiler warning only +} + +// We're using the timer to perform "maintenance" +// If signifcant FULL-refresh debt has accumulated, we will occasionally run FULL refreshes unprovoked. +// This prevents gradual build-up of debt, +// in case we don't have enough UNSPECIFIED refreshes to pay the debt back organically. +// The first refresh takes place shortly after user finishes interacting with the device; this does the bulk of the restoration +// Subsequent refreshes take place *much* less frequently. +// Hopefully an applet will want to render before this, meaning we can cancel the maintenance. +int32_t InkHUD::UpdateMediator::runOnce() +{ + if (debt > 0.0) { + LOG_DEBUG("debt=%f: performing maintenance", debt); + + // Ask WindowManager to redraw everything, purely for the refresh + // Todo: optimize? Could update without re-rendering + WindowManager::getInstance()->forceUpdate(EInk::UpdateTypes::FULL); + + // Record that we have paid back (some of) the FULL refresh debt + debt = max(debt - 1.0, 0.0); + + // Next maintenance refresh scheduled - long wait (an hour?) + return MAINTENANCE_MS; + } + + else + return endMaintenance(); +} + +// Begin periodically refreshing the display, to repay FULL-refresh debt +// We do this in case user doesn't have enough activity to repay it organically, with UpdateTypes::UNSPECIFIED +// After an initial refresh, to redraw as FULL, we only perform these maintenance refreshes very infrequently +// This gives the display a chance to heal by evaluating UNSPECIFIED as FULL, which is preferable +void InkHUD::UpdateMediator::beginMaintenance() +{ + LOG_DEBUG("Maintenance enabled"); + OSThread::setIntervalFromNow(MAINTENANCE_MS_INITIAL); + OSThread::enabled = true; +} + +// FULL-refresh debt is low enough that we no longer need to pay it back with periodic updates +int32_t InkHUD::UpdateMediator::endMaintenance() +{ + LOG_DEBUG("Maintenance disabled"); + return OSThread::disable(); +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/UpdateMediator.h b/src/graphics/niche/InkHUD/UpdateMediator.h new file mode 100644 index 000000000..e4c7c6786 --- /dev/null +++ b/src/graphics/niche/InkHUD/UpdateMediator.h @@ -0,0 +1,45 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + +Responsible for display health +- counts number of FULL vs FAST refresh +- suggests whether to use FAST or FULL, when not explicitly specified +- periodically requests update unprovoked, if required for display health + +*/ + +#pragma once + +#include "configuration.h" + +#include "graphics/niche/Drivers/EInk/EInk.h" + +namespace NicheGraphics::InkHUD +{ + +class UpdateMediator : protected concurrency::OSThread +{ + public: + UpdateMediator(); + + // Tell the mediator what we want, get told what we can have + Drivers::EInk::UpdateTypes evaluate(Drivers::EInk::UpdateTypes requested); + + // Determine which of two update types is more important to honor + Drivers::EInk::UpdateTypes prioritize(Drivers::EInk::UpdateTypes type1, Drivers::EInk::UpdateTypes type2); + + uint8_t fastPerFull = 5; // Ideal number of fast refreshes between full refreshes + float stressMultiplier = 2.0; // How bad for the display are extra fast refreshes beyond fastPerFull? + + private: + int32_t runOnce() override; + void beginMaintenance(); // Excessive debt: begin unprovoked refreshing of display, for health + int32_t endMaintenance(); // End unprovoked refreshing: debt paid + + float debt = 0.0; // How many full refreshes are due +}; + +} // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/WindowManager.cpp b/src/graphics/niche/InkHUD/WindowManager.cpp new file mode 100644 index 000000000..5188755c8 --- /dev/null +++ b/src/graphics/niche/InkHUD/WindowManager.cpp @@ -0,0 +1,1128 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +#include "./WindowManager.h" + +#include "mesh/NodeDB.h" +#include "rtc.h" + +// System applets +// Must be defined in .cpp to prevent a circular dependency with Applet base class +#include "./Applets/System/BatteryIcon/BatteryIconApplet.h" +#include "./Applets/System/Logo/LogoApplet.h" +#include "./Applets/System/Menu/MenuApplet.h" +#include "./Applets/System/Notification/NotificationApplet.h" +#include "./Applets/System/Pairing/PairingApplet.h" +#include "./Applets/System/Placeholder/PlaceholderApplet.h" +#include "./Applets/System/Tips/TipsApplet.h" + +using namespace NicheGraphics; + +InkHUD::WindowManager::WindowManager() : concurrency::OSThread("InkHUD WM") +{ + // Nothing for the timer to do just yet + OSThread::disable(); +} + +// Get or create the WindowManager singleton +InkHUD::WindowManager *InkHUD::WindowManager::getInstance() +{ + // Create the singleton instance of our class, if not yet done + static InkHUD::WindowManager *instance = new InkHUD::WindowManager(); + return instance; +} + +// Connect the driver, which is created independently is setupNicheGraphics() +void InkHUD::WindowManager::setDriver(Drivers::EInk *driver) +{ + // Make sure not already set + if (this->driver) { + LOG_ERROR("Driver already set"); + delay(2000); // Wait for native serial.. + assert(false); + } + + // Store the driver which was created in setupNicheGraphics() + this->driver = driver; + + // Determine the dimensions of the image buffer, in bytes. + // Along rows, pixels are stored 8 per byte. + // Not all display widths are divisible by 8. Need to make sure bytecount accommodates padding for these. + imageBufferWidth = ((driver->width - 1) / 8) + 1; + imageBufferHeight = driver->height; + + // Allocate the image buffer + imageBuffer = new uint8_t[imageBufferWidth * imageBufferHeight]; +} + +// Sets the ideal ratio of FAST updates to FULL updates +// We want as many FAST updates as possible, without causing gradual degradation of the display +// If explicitly requested, number of FAST updates may exceed fastPerFull value. +// In this case, the stressMultiplier is applied, causing the "FULL update debt" to increase by more than normal +// The stressMultplier helps the display recover from particularly taxing periods of use +// (Default arguments of 5,2 are very conservative values) +void InkHUD::WindowManager::setDisplayResilience(uint8_t fastPerFull = 5, float stressMultiplier = 2.0) +{ + mediator.fastPerFull = fastPerFull; + mediator.stressMultiplier = stressMultiplier; +} + +// Register a user applet with the WindowManager +// This is called in setupNicheGraphics() +// This should be the only time that specific user applets are mentioned in the code +// If a user applet is not added with this method, its code should not be built +void InkHUD::WindowManager::addApplet(const char *name, Applet *a, bool defaultActive, bool defaultAutoshow, uint8_t onTile) +{ + userApplets.push_back(a); + + // If requested, mark in settings that this applet should be active by default + // This means that it will be available for the user to cycle to with short-press of the button + // This is the default state only: user can activate or deactive applets through the menu. + // User's choice of active applets is stored in settings, and will be honored instead of these defaults, if present + if (defaultActive) + settings.userApplets.active[userApplets.size() - 1] = true; + + // If requested, mark in settings that this applet should "autoshow" by default + // This means that the applet will be automatically brought to foreground when it has new data to show + // This is the default state only: user can select which applets have this behavior through the menu + // User's selection is stored in settings, and will be honored instead of these defaults, if present + if (defaultAutoshow) + settings.userApplets.autoshow[userApplets.size() - 1] = true; + + // If specified, mark this as the default applet for a given tile index + // Used only to avoid placeholder applet "out of the box", when default settings have more than one tile + if (onTile != (uint8_t)-1) + settings.userTiles.displayedUserApplet[onTile] = userApplets.size() - 1; + + // The label that will be show in the applet selection menu, on the device + a->name = name; +} + +// Perform initial setup, and begin responding to incoming events +// First task once init is to show the boot screen +void InkHUD::WindowManager::begin() +{ + // Make sure we have set a driver + if (!this->driver) { + LOG_ERROR("Driver not set"); + delay(2000); // Wait for native serial.. + assert(false); + } + + loadDataFromFlash(); + + createSystemApplets(); + createSystemTiles(); + placeSystemTiles(); + assignSystemAppletsToTiles(); + + createUserApplets(); + createUserTiles(); + placeUserTiles(); + assignUserAppletsToTiles(); + refocusTile(); + + logoApplet->showBootScreen(); + forceUpdate(Drivers::EInk::FULL, false); // Update now, and wait here until complete + + deepSleepObserver.observe(¬ifyDeepSleep); + rebootObserver.observe(¬ifyReboot); + textMessageObserver.observe(textMessageModule); +#ifdef ARCH_ESP32 + lightSleepObserver.observe(¬ifyLightSleep); +#endif +} + +// Set-up special "system applets" +// These handle things like bootscreen, pop-up notifications etc +// They are processed separately from the user applets, because they might need to do "weird things" +// They also won't be activated or deactivated +void InkHUD::WindowManager::createSystemApplets() +{ + logoApplet = new LogoApplet; + pairingApplet = new PairingApplet; + tipsApplet = new TipsApplet; + notificationApplet = new NotificationApplet; + batteryIconApplet = new BatteryIconApplet; + menuApplet = new MenuApplet; + placeholderApplet = new PlaceholderApplet; + + // System applets are always active + logoApplet->activate(); + pairingApplet->activate(); + tipsApplet->activate(); + notificationApplet->activate(); + batteryIconApplet->activate(); + menuApplet->activate(); + placeholderApplet->activate(); + + // Add to the systemApplets vector + // Although system applets often need special handling, sometimes we can process them en-masse with this vector + // e.g. rendering, raising events + // Order of these entries determines Z-Index when rendering + systemApplets.push_back(logoApplet); + systemApplets.push_back(pairingApplet); + systemApplets.push_back(tipsApplet); + systemApplets.push_back(batteryIconApplet); + systemApplets.push_back(menuApplet); + systemApplets.push_back(notificationApplet); + // Note: placeholder applet is technically a system applet, but it renders in WindowManager::renderPlaceholders +} + +void InkHUD::WindowManager::createSystemTiles() +{ + fullscreenTile = new Tile; + notificationTile = new Tile; + batteryIconTile = new Tile; +} + +void InkHUD::WindowManager::placeSystemTiles() +{ + fullscreenTile->placeSystemTile(0, 0, getWidth(), getHeight()); + notificationTile->placeSystemTile(0, 0, getWidth(), 20); // Testing only: constant value + + // Todo: appropriate sizing for the battery icon + const uint16_t batteryIconHeight = Applet::getHeaderHeight() - (2 * 2); + uint16_t batteryIconWidth = batteryIconHeight * 1.8; + + batteryIconTile->placeSystemTile(getWidth() - batteryIconWidth, 2, batteryIconWidth, batteryIconHeight); +} + +// Assign a system applet to the fullscreen tile +// Rendering of user tiles is suspended when the fullscreen tile is occupied +void InkHUD::WindowManager::claimFullscreen(InkHUD::Applet *a) +{ + // Make sure that only system applets use the fullscreen tile + bool isSystemApplet = false; + for (Applet *sa : systemApplets) { + if (sa == a) { + isSystemApplet = true; + break; + } + } + assert(isSystemApplet); + + fullscreenTile->assignApplet(a); +} + +// Clear the fullscreen tile, unlinking whichever system applet is assigned +// This allows the normal rendering of user tiles to resume +void InkHUD::WindowManager::releaseFullscreen() +{ + // Make sure the applet is ready to release the tile + assert(!fullscreenTile->getAssignedApplet()->isForeground()); + + // Break the link between the applet and the fullscreen tile + fullscreenTile->assignApplet(nullptr); +} + +// Some system applets can be assigned to a tile at boot +// These are applets which do have their own tile, and whose assignment never changes +// Applets which: +// - share the fullscreen tile (e.g. logoApplet, pairingApplet), +// - render on user tiles (e.g. menuApplet, placeholderApplet), +// are assigned to the tile only when needed +void InkHUD::WindowManager::assignSystemAppletsToTiles() +{ + notificationTile->assignApplet(notificationApplet); + batteryIconTile->assignApplet(batteryIconApplet); +} + +// Activate or deactivate user applets, to match settings +// Called at boot, or after run-time config changes via menu +// Note: this method does not instantiate the applets; +// this is done in setupNicheGraphics, with WindowManager::addApplet +void InkHUD::WindowManager::createUserApplets() +{ + // Deactivate and remove any no-longer-needed applets + for (uint8_t i = 0; i < userApplets.size(); i++) { + Applet *a = userApplets.at(i); + + // If the applet is active, but settings say it shouldn't be: + // - run applet's custom deactivation code + // - mark applet as inactive (internally) + if (a->isActive() && !settings.userApplets.active[i]) + a->deactivate(); + } + + // Activate and add any new applets + for (uint8_t i = 0; i < userApplets.size() && i < MAX_USERAPPLETS_GLOBAL; i++) { + + // If not activated, but it now should be: + // - run applet's custom activation code + // - mark applet as active (internally) + if (!userApplets.at(i)->isActive() && settings.userApplets.active[i]) + userApplets.at(i)->activate(); + } +} + +void InkHUD::WindowManager::createUserTiles() +{ + // Delete any tiles which currently exist + for (Tile *t : userTiles) + delete t; + userTiles.clear(); + + // Create new tiles + for (uint8_t i = 0; i < settings.userTiles.count; i++) { + Tile *t = new Tile; + userTiles.push_back(t); + } +} + +void InkHUD::WindowManager::placeUserTiles() +{ + // Calculate the display region occupied by each tile + // This determines how pixels are translated from applet-space to windowmanager-space + for (uint8_t i = 0; i < userTiles.size(); i++) + userTiles.at(i)->placeUserTile(settings.userTiles.count, i); +} + +void InkHUD::WindowManager::assignUserAppletsToTiles() +{ + // Set "assignedApplet" property + // Which applet should be initially shown on a tile? + // This is preserved between reboots, but the value needs validating at startup + for (uint8_t i = 0; i < userTiles.size(); i++) { + Tile *t = userTiles.at(i); + + // Check whether tile can display the previously shown applet again + uint8_t oldIndex = settings.userTiles.displayedUserApplet[i]; // Previous index in WindowManager::userApplets + bool canRestore = true; + if (oldIndex > userApplets.size() - 1) // Check if old index is now out of bounds + canRestore = false; + else if (!settings.userApplets.active[oldIndex]) // Check that old applet is still activated + canRestore = false; + else { // Check that the old applet isn't now shown already on a different tile + for (uint8_t i2 = 0; i2 < i; i2++) { + if (settings.userTiles.displayedUserApplet[i2] == oldIndex) { + canRestore = false; + break; + } + } + } + + // Restore previously shown applet if possible, + // otherwise assign nullptr, which will render specially using placeholderApplet + if (canRestore) { + Applet *a = userApplets.at(oldIndex); + t->assignApplet(a); + a->bringToForeground(); + } else { + t->assignApplet(nullptr); + settings.userTiles.displayedUserApplet[i] = -1; // Update settings: current tile has no valid applet + } + } +} + +void InkHUD::WindowManager::refocusTile() +{ + // Validate "focused tile" setting + // - info: focused tile responds to button presses: applet cycling, menu, etc + // - if number of tiles changed, might now be out of index + if (settings.userTiles.focused >= userTiles.size()) + settings.userTiles.focused = 0; + + // Give "focused tile" a valid applet + // - scan for another valid applet, which we can addSubstitution + // - reason: nextApplet() won't cycle if no applet is assigned + Tile *focusedTile = userTiles.at(settings.userTiles.focused); + if (!focusedTile->getAssignedApplet()) { + // Search for available applets + for (uint8_t i = 0; i < userApplets.size(); i++) { + Applet *a = userApplets.at(i); + if (a->isActive() && !a->isForeground()) { + // Found a suitable applet + // Assign it to the focused tile + focusedTile->assignApplet(a); + a->bringToForeground(); + settings.userTiles.displayedUserApplet[settings.userTiles.focused] = i; // Record change: persist after reboot + break; + } + } + } +} + +// Callback for deepSleepObserver +// Returns 0 to signal that we agree to sleep now +int InkHUD::WindowManager::beforeDeepSleep(void *unused) +{ + // Notify all applets that we're shutting down + for (Applet *ua : userApplets) { + ua->onDeactivate(); + ua->onShutdown(); + } + for (Applet *sa : userApplets) { + // Note: no onDeactivate. System applets are always active. + sa->onShutdown(); + } + + // User has successfull executed a safe shutdown + // We don't need to nag at boot anymore + settings.tips.safeShutdownSeen = true; + + saveDataToFlash(); + + // Display the shutdown screen, and wait here until the update is complete + logoApplet->showShutdownScreen(); + forceUpdate(Drivers::EInk::UpdateTypes::FULL, false); + + return 0; // We agree: deep sleep now +} + +// Callback for rebootObserver +// Same as shutdown, without drawing the logoApplet +// Makes sure we don't lose message history / InkHUD config +int InkHUD::WindowManager::beforeReboot(void *unused) +{ + + // Notify all applets that we're "shutting down" + // They don't need to know that it's really a reboot + for (Applet *a : userApplets) { + a->onDeactivate(); + a->onShutdown(); + } + for (Applet *sa : userApplets) { + // Note: no onDeactivate. System applets are always active. + sa->onShutdown(); + } + + saveDataToFlash(); + + return 0; // No special status to report. Ignored anyway by this Observable +} + +#ifdef ARCH_ESP32 +// Callback for lightSleepObserver +// Make sure the display is not partway through an update when we begin light sleep +// This is because some displays require active input from us to terminate the update process, and protect the panel hardware +int InkHUD::WindowManager::beforeLightSleep(void *unused) +{ + if (driver->busy()) { + LOG_INFO("Waiting for display"); + driver->await(); // Wait here for update to complete + } + + return 0; // No special status to report. Ignored anyway by this Observable +} +#endif + +// Callback when a new text message is received +// Caches the most recently received message, for use by applets +// Rx does not trigger a save to flash, however the data *will* be saved alongside other during shutdown, etc. +// Note: this is different from devicestate.rx_text_message, which may contain an *outgoing* message +int InkHUD::WindowManager::onReceiveTextMessage(const meshtastic_MeshPacket *packet) +{ + // Short circuit: don't store outgoing messages + if (getFrom(packet) == nodeDB->getNodeNum()) + return 0; + + // Short circuit: don't store "emoji reactions" + // Possibly some implemetation of this in future? + if (packet->decoded.emoji) + return 0; + + // Determine whether the message is broadcast or a DM + // Store this info to prevent confusion after a reboot + // Avoids need to compare timestamps, because of situation where "future" messages block newly received, if time not set + latestMessage.wasBroadcast = isBroadcast(packet->to); + + // Pick the appropriate variable to store the message in + MessageStore::Message *storedMessage = latestMessage.wasBroadcast ? &latestMessage.broadcast : &latestMessage.dm; + + // Store nodenum of the sender + // Applets can use this to fetch user data from nodedb, if they want + storedMessage->sender = packet->from; + + // Store the time (epoch seconds) when message received + storedMessage->timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time + + // Store the channel + // - (potentially) used to determine whether notification shows + // - (potentially) used to determine which applet to focus + storedMessage->channelIndex = packet->channel; + + // Store the text + // Need to specify manually how many bytes, because source not null-terminated + storedMessage->text = + std::string(&packet->decoded.payload.bytes[0], &packet->decoded.payload.bytes[packet->decoded.payload.size]); + + return 0; // Tell caller to continue notifying other observers. (No reason to abort this event) +} + +// Triggered by an input source when a short-press fires +// The input source is a separate component; not part of InkHUD +// It is connected in setupNicheGraphics() +void InkHUD::WindowManager::handleButtonShort() +{ + // If notification is open: close it + if (notificationApplet->isForeground()) { + notificationApplet->dismiss(); + forceUpdate(EInk::UpdateTypes::FULL); // Redraw everything, to clear the notification + } + + // If window manager is locked: lock owner handles button + else if (lockOwner) + lockOwner->onButtonShortPress(); + + // Normally: next applet + else + nextApplet(); +} + +// Triggered by an input source when a long-press fires +// The input source is a separate component; not part of InkHUD +// It is connected in setupNicheGraphics() +// Note: input source should raise this while button still held +void InkHUD::WindowManager::handleButtonLong() +{ + if (lockOwner) + lockOwner->onButtonLongPress(); + + else + menuApplet->show(userTiles.at(settings.userTiles.focused)); +} + +// On the currently focussed tile: cycle to the next available user applet +// Applets available for this must be activated, and not already displayed on another tile +void InkHUD::WindowManager::nextApplet() +{ + Tile *t = userTiles.at(settings.userTiles.focused); + + // Abort if zero applets available + // nullptr means WindowManager::refocusTile determined that there were no available applets + if (!t->getAssignedApplet()) + return; + + // Find the index of the applet currently shown on the tile + uint8_t appletIndex = -1; + for (uint8_t i = 0; i < userApplets.size(); i++) { + if (userApplets.at(i) == t->getAssignedApplet()) { + appletIndex = i; + break; + } + } + + // Confirm that we did find the applet + assert(appletIndex != (uint8_t)-1); + + // Iterate forward through the WindowManager::applets, looking for the next valid applet + Applet *nextValidApplet = nullptr; + // for (uint8_t i = (appletIndex + 1) % applets.size(); i != appletIndex; i = (i + 1) % applets.size()) { + for (uint8_t i = 1; i < userApplets.size(); i++) { + uint8_t newAppletIndex = (appletIndex + i) % userApplets.size(); + Applet *a = userApplets.at(newAppletIndex); + + // Looking for an applet which is active (enabled by user), but currently in background + if (a->isActive() && !a->isForeground()) { + nextValidApplet = a; + settings.userTiles.displayedUserApplet[settings.userTiles.focused] = + newAppletIndex; // Remember this setting between boots! + break; + } + } + + // Confirm that we found another applet + if (!nextValidApplet) + return; + + // Hide old applet, show new applet + t->getAssignedApplet()->sendToBackground(); + t->assignApplet(nextValidApplet); + nextValidApplet->bringToForeground(); + forceUpdate(EInk::UpdateTypes::FAST); // bringToForeground already requested, but we're manually forcing FAST +} + +// Focus on a different tile +// The "focused tile" is the one which cycles applets on user button press, +// and the one where the menu will be displayed +// Note: this method is only used by an aux button +// The menuApplet manually performs a subset of these actions, to avoid disturbing the stale image on adjacent tiles +void InkHUD::WindowManager::nextTile() +{ + // Close the menu applet if open + // We done *really* want to do this, but it simplifies handling *a lot* + if (menuApplet->isForeground()) + menuApplet->sendToBackground(); + + // Seems like some system applet other than menu is open. Pairing? Booting? + if (!canRequestUpdate()) + return; + + // Swap to next tile + settings.userTiles.focused = (settings.userTiles.focused + 1) % settings.userTiles.count; + + // Make sure that we don't get stuck on the placeholder tile + // changeLayout reassigns applets to tiles + changeLayout(); + + // Ask the tile to draw an indicator showing which tile is now focused + // Requests a render + userTiles.at(settings.userTiles.focused)->requestHighlight(); +} + +// Perform necessary reconfiguration when user changes number of tiles (or rotation) at run-time +// Call after changing settings.tiles.count +void InkHUD::WindowManager::changeLayout() +{ + // Recreate tiles + // - correct number created, from settings.userTiles.count + // - set dimension and position of tiles, according to layout + createUserTiles(); + placeUserTiles(); + placeSystemTiles(); + + // Handle fewer tiles + // - background any applets which have lost their tile + findOrphanApplets(); + + // Handle more tiles + // - create extra applets + // - assign them to the new extra tiles + createUserApplets(); + assignUserAppletsToTiles(); + + // Focus a valid tile + // - info: focused tile is the one which cycles applets when user button pressed + // - may now be out of bounds if tile count has decreased + refocusTile(); + + // Restore menu + // - its tile was just destroyed and recreated (createUserTiles) + // - its assignment was cleared (assignUserAppletsToTiles) + if (menuApplet->isForeground()) { + Tile *ft = userTiles.at(settings.userTiles.focused); + menuApplet->show(ft); + } + + // Force-render + // - redraw all applets + forceUpdate(EInk::UpdateTypes::FAST); +} + +// Perform necessary reconfiguration when user activates or deactivates applets at run-time +// Call after changing settings.userApplets.active +void InkHUD::WindowManager::changeActivatedApplets() +{ + assert(menuApplet->isForeground()); + + // Activate or deactivate applets + // - to match value of settings.userApplets.active + createUserApplets(); + + // Assign the placeholder applet + // - if applet was foreground on a tile when deactivated, swap it with a placeholder + // - placeholder applet may be assigned to multiple tiles, if needed + assignUserAppletsToTiles(); + + // Ensure focused tile has a valid applet + // - if focused tile's old applet was deactivated, give it a real applet, instead of placeholder + // - reason: nextApplet() won't cycle applets if placeholder is shown + refocusTile(); + + // Restore menu + // - its assignment was cleared (assignUserAppletsToTiles) + if (menuApplet->isForeground()) { + Tile *ft = userTiles.at(settings.userTiles.focused); + menuApplet->show(ft); + } + + // Force-render + // - redraw all applets + forceUpdate(EInk::UpdateTypes::FAST); +} + +// Change whether the battery icon is displayed (top left corner) +// Don't toggle the OptionalFeatures value before calling this, our method handles it internally +void InkHUD::WindowManager::toggleBatteryIcon() +{ + assert(batteryIconApplet->isActive()); + settings.optionalFeatures.batteryIcon = !settings.optionalFeatures.batteryIcon; // Preserve the change between boots + + // Show or hide the applet + if (settings.optionalFeatures.batteryIcon) + batteryIconApplet->bringToForeground(); + else + batteryIconApplet->sendToBackground(); + + // Force-render + // - redraw all applets + forceUpdate(EInk::UpdateTypes::FAST); +} + +// Allow applets to suppress notifications +// Applets will be asked whether they approve, before a notification is shown via the NotificationApplet +// An applet might want to suppress a notification if the applet itself already displays this info +// Example: AllMessageApplet should not approve notifications for messages, if it is in foreground +bool InkHUD::WindowManager::approveNotification(InkHUD::Notification &n) +{ + // Ask all currently displayed applets + for (Tile *ut : userTiles) { + Applet *ua = ut->getAssignedApplet(); + if (ua && !ua->approveNotification(n)) + return false; + } + + // Nobody objected + return true; +} + +// Set a flag, which will be picked up by runOnce, ASAP. +// Quite likely, multiple applets will all want to respond to one event (Observable, etc) +// Each affected applet can independently call requestUpdate(), and all share the one opportunity to render, at next runOnce +void InkHUD::WindowManager::requestUpdate() +{ + requestingUpdate = true; + + // We will run the thread as soon as we loop(), + // after all Applets have had a chance to observe whatever event set this off + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; +} + +// requestUpdate will not actually update if no requests were made by applets which are actually visible +// This can occur, because applets requestUpdate even from the background, +// in case the user's autoshow settings permit them to be moved to foreground. +// Sometimes, however, we will want to trigger a display update manually, in the absense of any sort of applet event +// Display health, for example. +// In these situations, we use forceUpdate +void InkHUD::WindowManager::forceUpdate(EInk::UpdateTypes type, bool async) +{ + requestingUpdate = true; + forcingUpdate = true; + forcedUpdateType = type; + + // Normally, we need to start the timer, in case the display is busy and we briefly defer the update + if (async) { + // We will run the thread as soon as we loop(), + // after all Applets have had a chance to observe whatever event set this off + OSThread::setIntervalFromNow(0); + OSThread::enabled = true; + runASAP = true; + } + + // If the update is *not* asynchronous, we begin the render process directly here + // so that it can block code flow while running + else + render(false); +} + +// Receives rendered image data from an Applet, via a tile +// When applets render, they output pixel data relative to their own left / top edges +// They pass this pixel data to tile, which offsets the pixels, making them relative to the display left / top edges +// That data is then passed to this method, which applies any rotation, then places the pixels into the image buffer +// That image buffer is the fully-formatted data handed off to the driver +void InkHUD::WindowManager::handleTilePixel(int16_t x, int16_t y, Color c) +{ + rotatePixelCoords(&x, &y); + setBufferPixel(x, y, c); +} + +// Width of the display, relative to rotation +uint16_t InkHUD::WindowManager::getWidth() +{ + if (settings.rotation % 2) + return driver->height; + else + return driver->width; +} + +// Height of the display, relative to rotation +uint16_t InkHUD::WindowManager::getHeight() +{ + if (settings.rotation % 2) + return driver->width; + else + return driver->height; +} + +// How many user applets have been built? Includes applets which have been inactivated by user config +uint8_t InkHUD::WindowManager::getAppletCount() +{ + return userApplets.size(); +} + +// A tidy title for applets: used on-display in some situations +// Index is the order in the WindowManager::userApplets vector +// This is the same order that applets were added in setupNicheGraphics +const char *InkHUD::WindowManager::getAppletName(uint8_t index) +{ + return userApplets.at(index)->name; +} + +// Allows a system applet to prevent other applets from temporarily requesting updates +// All user applets will honor this. Some system applets might not, although they probably should +// WindowManager::forceUpdate will ignore this lock +void InkHUD::WindowManager::lock(Applet *owner) +{ + // Only one system applet may lock render at once + assert(!lockOwner); + + // Only system applets may lock rendering + for (Applet *a : userApplets) + assert(owner != a); + + lockOwner = owner; +} + +// Remove a lock placed by a system applet, which prevents other applets from rendering +void InkHUD::WindowManager::unlock(Applet *owner) +{ + assert(lockOwner = owner); + lockOwner = nullptr; + + // Raise this as an event (system applets only) + // - in case applet waiting for lock + // - in case applet relinquished its lock earlier, and wants it back + for (Applet *sa : systemApplets) { + // Don't raise event for the applet which is calling unlock + // - avoid loop of unlock->lock (some implementations of Applet::onLockAvailable) + if (sa != owner) + sa->onLockAvailable(); + } +} + +// Is an applet blocked from requesting update by a current lock? +// Applets are allowed to request updates if there is no lock, or if they are the owner of the lock +// If a == nullptr, checks permission "for everyone and anyone" +bool InkHUD::WindowManager::canRequestUpdate(Applet *a) +{ + if (!lockOwner) + return true; + else if (lockOwner == a) + return true; + else + return false; +} + +// Get the applet which is currently locking rendering +// We might be able to convince it release its lock, if we want it instead +InkHUD::Applet *InkHUD::WindowManager::whoLocked() +{ + return WindowManager::lockOwner; +} + +// Runs at regular intervals +// WindowManager's uses of this include: +// - postponing render: until next loop(), allowing all applets to be notified of some Mesh event before render +// - queuing another render: while one is already is progress +int32_t InkHUD::WindowManager::runOnce() +{ + // If an applet asked to render, and hardware is able, lets try now + if (requestingUpdate && !driver->busy()) { + render(); + } + + // If our render() call failed, try again shortly + // otherwise, stop our thread until next update due + if (requestingUpdate) + return 250UL; + else + return OSThread::disable(); +} + +// Some applets may be permitted to bring themselved to foreground, to show new data +// User selects which applets have this permission via on-screen menu +// Priority is determined by the order which applets were added to WindowManager in setupNicheGraphics +// We will only autoshow one applet +void InkHUD::WindowManager::autoshow() +{ + for (uint8_t i = 0; i < userApplets.size(); i++) { + Applet *a = userApplets.at(i); + if (a->wantsToAutoshow() // Applet wants to become foreground + && !a->isForeground() // Not yet foreground + && settings.userApplets.autoshow[i] // User permits this applet to autoshow + && canRequestUpdate()) // Updates not currently blocked by system applet + { + Tile *t = userTiles.at(settings.userTiles.focused); // Get focused tile + t->getAssignedApplet()->sendToBackground(); // Background whichever applet is already on the tile + t->assignApplet(a); // Assign our new applet to tile + a->bringToForeground(); // Foreground our new applet + + // Check if autoshown applet shows the same information as notification intended to + // In this case, we can dismiss the notification before it is shown + // Note: we are re-running the approval process. This normally occurs when the notification is initially triggered. + if (notificationApplet->isForeground() && !notificationApplet->isApproved()) + notificationApplet->dismiss(); + + break; // One autoshow only! Avoid conflicts + } + } +} + +// Check whether an update is justified +// We usually require that a foreground applet requested the update, +// but forceUpdate call will bypass these checks. +// Abstraction for WindowManager::render only +bool InkHUD::WindowManager::shouldUpdate() +{ + bool should = false; + + // via forceUpdate + should |= forcingUpdate; + + // via user applet + for (Tile *ut : userTiles) { + Applet *ua = ut->getAssignedApplet(); + if (ua // Tile has valid applet + && ua->wantsToRender() // This applet requested display update + && ua->isForeground() // This applet is currently shown + && canRequestUpdate()) // Requests are not currently locked + { + should = true; + break; + } + } + + // via system applet + for (Applet *sa : systemApplets) { + if (sa->wantsToRender() // This applet requested + && sa->isForeground() // This applet is currently shown + && canRequestUpdate(sa)) // Requests are not currently locked, or this applet owns the lock + { + should = true; + break; + } + } + + return should; +} + +// Determine which type of E-Ink update the display will perform, to change the image. +// Considers the needs of the various applets, then weighs against display health. +// An update type specified by forceUpdate will be granted with no further questioning. +// Abstraction for WindowManager::render only +Drivers::EInk::UpdateTypes InkHUD::WindowManager::selectUpdateType() +{ + // Ask applets which update type they would prefer + // Some update types take priority over others + EInk::UpdateTypes type = EInk::UpdateTypes::UNSPECIFIED; + if (forcingUpdate) { + // Update type was manually specified via forceUpdate + type = forcedUpdateType; + } else { + // User applets + for (Tile *ut : userTiles) { + Applet *ua = ut->getAssignedApplet(); + if (ua && ua->isForeground() && canRequestUpdate()) + type = mediator.prioritize(type, ua->wantsUpdateType()); + } + // System Applets + for (Applet *sa : systemApplets) { + if (sa->isForeground() && canRequestUpdate(sa)) + type = mediator.prioritize(type, sa->wantsUpdateType()); + } + } + + // Tell the mediator what update type the applets deciced on, + // find out what update type the mediator will actually allow us to have + type = mediator.evaluate(type); + + return type; +} + +// Run the drawing operations of any user applets which are currently displayed +// Pixel output is placed into the framebuffer, ready for handoff to the EInk driver +// Abstraction for WindowManager::render only +void InkHUD::WindowManager::renderUserApplets() +{ + // Don't render any user applets if the screen is covered by a system applet using the fullscreen tile + if (fullscreenTile->getAssignedApplet()) + return; + + // For each tile + for (Tile *ut : userTiles) { + Applet *ua = ut->getAssignedApplet(); // Get the applet on the tile + + // Don't render if tile has no applet. Handled in renderPlaceholders + if (!ua) + continue; + + // Don't render the menu applet, Handled by renderSystemApplets + if (ua == menuApplet) + continue; + + uint32_t start = millis(); + ua->render(); // Draw! + uint32_t stop = millis(); + LOG_DEBUG("%s took %dms to render", ua->name, stop - start); + } +} + +// Run the drawing operations of any system applets which are currently displayed +// Pixel output is placed into the framebuffer, ready for handoff to the EInk driver +// Abstraction for WindowManager::render only +void InkHUD::WindowManager::renderSystemApplets() +{ + // Each system applet + for (Applet *sa : systemApplets) { + // Skip if not shown + if (!sa->isForeground()) + continue; + + // Don't draw the battery overtop the menu + // Todo: smarter way to handle this + if (sa == batteryIconApplet && menuApplet->isForeground()) + continue; + + // Skip applet if fullscreen tile is in use, but not used by this applet + // Applet is "obscured" + if (fullscreenTile->getAssignedApplet() && fullscreenTile->getAssignedApplet() != sa) + continue; + + // uint32_t start = millis(); // Debugging only: runtime + sa->render(); // Draw! + // uint32_t stop = millis(); // Debugging only: runtime + // LOG_DEBUG("%s (system) took %dms to render", (sa->name == nullptr) ? "Unnamed" : sa->name, stop - start); + } +} + +// In some situations (e.g. layout or applet selection changes), +// a user tile can end up without an assigned applet. +// In this case, we will fill the empty space with diagonal lines. +void InkHUD::WindowManager::renderPlaceholders() +{ + // Don't draw if obscured by the fullscreen tile + if (fullscreenTile->getAssignedApplet()) + return; + + for (Tile *ut : userTiles) { + // If no applet assigned + if (!ut->getAssignedApplet()) { + ut->assignApplet(placeholderApplet); + placeholderApplet->render(); + ut->assignApplet(nullptr); + } + } +} + +// Make an attempt to gather image data from some / all applets, and update the display +// Might not be possible right now, if update already is progress. +void InkHUD::WindowManager::render(bool async) +{ + // Make sure the display is ready for a new update + if (async) { + // Previous update still running, Will try again shortly, via runOnce() + if (driver->busy()) + return; + } else { + // Wait here for previous update to complete + driver->await(); + } + + // (Potentially) change applet to display new info, + // then check if this newly displayed applet makes a pending notification redundant + autoshow(); + + // If an update is justified. + // We don't know this until after autoshow has run, as new applets may now be in foreground + if (shouldUpdate()) { + + // Decide which technique the display will use to change image + EInk::UpdateTypes updateType = selectUpdateType(); + + // Render the new image + clearBuffer(); + renderUserApplets(); + renderSystemApplets(); + renderPlaceholders(); + + // Tell display to begin process of drawing new image + LOG_INFO("Updating display"); + driver->update(imageBuffer, updateType); + + // If not async, wait here until the update is complete + if (!async) + driver->await(); + } else + LOG_DEBUG("Not updating display"); + + // Our part is done now. + // If update is async, the display hardware is still performing the update process, + // but that's all handled by NicheGraphics::Drivers::EInk + + // Tidy up, ready for a new request + requestingUpdate = false; + forcingUpdate = false; + forcedUpdateType = EInk::UpdateTypes::UNSPECIFIED; +} + +// Set a ready-to-draw pixel into the image buffer +// All rotations / translations have already taken place: this buffer data is formatted ready for the driver +void InkHUD::WindowManager::setBufferPixel(int16_t x, int16_t y, Color c) +{ + uint32_t byteNum = (y * imageBufferWidth) + (x / 8); // X data is 8 pixels per byte + uint8_t bitNum = 7 - (x % 8); // Invert order: leftmost bit (most significant) is leftmost pixel of byte. + + bitWrite(imageBuffer[byteNum], bitNum, c); +} + +// Applies the system-wide rotation to pixel positions +// This step is applied to image data which has already been translated by a Tile object +// This is the final step before the pixel is placed into the image buffer +// No return: values of the *x and *y parameters are modified by the method +void InkHUD::WindowManager::rotatePixelCoords(int16_t *x, int16_t *y) +{ + // Apply a global rotation to pixel locations + int16_t x1 = 0; + int16_t y1 = 0; + switch (settings.rotation) { + case 0: + x1 = *x; + y1 = *y; + break; + case 1: + x1 = (driver->width - 1) - *y; + y1 = *x; + break; + case 2: + x1 = (driver->width - 1) - *x; + y1 = (driver->height - 1) - *y; + break; + case 3: + x1 = *y; + y1 = (driver->height - 1) - *x; + break; + } + *x = x1; + *y = y1; +} + +// Manually fill the image buffer with WHITE +// Clears any old drawing +void InkHUD::WindowManager::clearBuffer() +{ + memset(imageBuffer, 0xFF, imageBufferHeight * imageBufferWidth); +} + +// Seach for any applets which believe they are foreground, but no longer have a valid tile +// Tidies up after layout changes at runtime +void InkHUD::WindowManager::findOrphanApplets() +{ + for (uint8_t ia = 0; ia < userApplets.size(); ia++) { + Applet *a = userApplets.at(ia); + + // Applet doesn't believe it is displayed: not orphaned + if (!a->isForeground()) + continue; + + // Check each tile, to see if anyone claims this applet + bool foundOwner = false; + for (uint8_t it = 0; it < userTiles.size(); it++) { + Tile *t = userTiles.at(it); + // A tile claims this applet: not orphaned + if (t->getAssignedApplet() == a) { + foundOwner = true; + break; + } + } + + // Orphan found + // Tell the applet that no tile is currently displaying it + // This allows the focussed tile to cycle to this applet again by pressing user button + if (!foundOwner) + a->sendToBackground(); + } +} + +#endif \ No newline at end of file diff --git a/src/graphics/niche/InkHUD/WindowManager.h b/src/graphics/niche/InkHUD/WindowManager.h new file mode 100644 index 000000000..f701233e2 --- /dev/null +++ b/src/graphics/niche/InkHUD/WindowManager.h @@ -0,0 +1,177 @@ +#ifdef MESHTASTIC_INCLUDE_INKHUD + +/* + + Singleton class, which manages the broadest InkHUD behaviors + + Tasks include: + - containing instances of Tiles and Applets + - co-ordinating display updates + - interacting with other NicheGraphics componets, such as the driver, and input sources + - handling system-wide events (e.g. shutdown) + +*/ + +#pragma once + +#include "configuration.h" + +#include + +#include "main.h" +#include "modules/TextMessageModule.h" +#include "power.h" +#include "sleep.h" + +#include "./Applet.h" +#include "./Applets/System/Notification/Notification.h" +#include "./Persistence.h" +#include "./Tile.h" +#include "./Types.h" +#include "./UpdateMediator.h" +#include "graphics/niche/Drivers/EInk/EInk.h" + +namespace NicheGraphics::InkHUD +{ + +class Applet; +class Tile; + +class LogoApplet; +class MenuApplet; +class NotificationApplet; + +class WindowManager : protected concurrency::OSThread +{ + public: + static WindowManager *getInstance(); // Get or create singleton instance + + void setDriver(NicheGraphics::Drivers::EInk *driver); // Assign a driver class + void setDisplayResilience(uint8_t fastPerFull, float stressMultiplier); // How many FAST updates before FULL + void addApplet(const char *name, Applet *a, bool defaultActive = false, bool defaultAutoshow = false, + uint8_t onTile = -1); // Select which applets are used with InkHUD + void begin(); // Start running the window manager (provisioning done) + + void createSystemApplets(); // Instantiate and activate system applets + void createSystemTiles(); // Instantiate tiles which host system applets + void assignSystemAppletsToTiles(); + void placeSystemTiles(); // Set position and size + void claimFullscreen(Applet *sa); // Assign a system applet to the fullscreen tile + void releaseFullscreen(); // Remove any system applet from the fullscreen tile + + void createUserApplets(); // Activate user's selected applets + void createUserTiles(); // Instantiate enough tiles for user's selected layout + void assignUserAppletsToTiles(); + void placeUserTiles(); // Automatically place tiles, according to user's layout + void refocusTile(); // Ensure focused tile has a valid applet + + int beforeDeepSleep(void *unused); // Prepare for shutdown + int beforeReboot(void *unused); // Prepare for reboot + int onReceiveTextMessage(const meshtastic_MeshPacket *packet); // Store most recent text message +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); // Prepare for light sleep +#endif + + void handleButtonShort(); // User button: short press + void handleButtonLong(); // User button: long press + + void nextApplet(); // Cycle through user applets + void nextTile(); // Focus the next tile (when showing multiple applets at once) + + void changeLayout(); // Change tile layout or count + void changeActivatedApplets(); // Change which applets are activated + void toggleBatteryIcon(); // Change whether the battery icon is shown + bool approveNotification(Notification &n); // Ask applets if a notification is worth showing + + void handleTilePixel(int16_t x, int16_t y, Color c); // Apply rotation, then store the pixel in framebuffer + void requestUpdate(); // Update display, if a foreground applet has info it wants to show + void forceUpdate(Drivers::EInk::UpdateTypes type = Drivers::EInk::UpdateTypes::UNSPECIFIED, + bool async = true); // Update display, regardless of whether any applets requested this + + uint16_t getWidth(); // Display width, relative to rotation + uint16_t getHeight(); // Display height, relative to rotation + uint8_t getAppletCount(); // How many user applets are available, including inactivated + const char *getAppletName(uint8_t index); // By order in userApplets + + void lock(Applet *owner); // Allows system applets to prevent other applets triggering a refresh + void unlock(Applet *owner); // Allows normal updating of user applets to continue + bool canRequestUpdate(Applet *a = nullptr); // Checks if allowed to request an update (not locked by other applet) + Applet *whoLocked(); // Find which applet is blocking update requests, if any + + protected: + WindowManager(); // Private constructor for singleton + + int32_t runOnce() override; + + void clearBuffer(); // Empty the framebuffer + void autoshow(); // Show a different applet, to display new info + bool shouldUpdate(); // Check if reason to change display image + Drivers::EInk::UpdateTypes selectUpdateType(); // Determine how the display hardware will perform the image update + void renderUserApplets(); // Draw all currently displayed user applets to the frame buffer + void renderSystemApplets(); // Draw all currently displayed system applets to the frame buffer + void renderPlaceholders(); // Draw diagonal lines on user tiles which have no assigned applet + void render(bool async = true); // Attempt to update the display + + void setBufferPixel(int16_t x, int16_t y, Color c); // Place pixels into the frame buffer. All translation / rotation done. + void rotatePixelCoords(int16_t *x, int16_t *y); // Apply the display rotation + + void findOrphanApplets(); // Find any applets left-behind when layout changes + + // Get notified when the system is shutting down + CallbackObserver deepSleepObserver = + CallbackObserver(this, &WindowManager::beforeDeepSleep); + + // Get notified when the system is rebooting + CallbackObserver rebootObserver = + CallbackObserver(this, &WindowManager::beforeReboot); + + // Cache *incoming* text messages, for use by applets + CallbackObserver textMessageObserver = + CallbackObserver(this, &WindowManager::onReceiveTextMessage); + +#ifdef ARCH_ESP32 + // Get notified when the system is entering light sleep + CallbackObserver lightSleepObserver = + CallbackObserver(this, &WindowManager::beforeLightSleep); +#endif + + NicheGraphics::Drivers::EInk *driver = nullptr; + uint8_t *imageBuffer; // Fed into driver + uint16_t imageBufferHeight; + uint16_t imageBufferWidth; + uint32_t imageBufferSize; // Bytes + + // Encapsulates decision making about E-Ink update types + // Responsible for display health + UpdateMediator mediator; + + // User Applets + std::vector userApplets; + std::vector userTiles; + + // System Applets + std::vector systemApplets; + Tile *fullscreenTile = nullptr; + Tile *notificationTile = nullptr; + Tile *batteryIconTile = nullptr; + LogoApplet *logoApplet; + Applet *pairingApplet; + Applet *tipsApplet; + NotificationApplet *notificationApplet; + Applet *batteryIconApplet; + MenuApplet *menuApplet; + Applet *placeholderApplet; + + // requestUpdate + bool requestingUpdate = false; // WindowManager::render run pending + + // forceUpdate + bool forcingUpdate = false; // WindowManager::render run pending, guaranteed no skip of update + Drivers::EInk::UpdateTypes forcedUpdateType = Drivers::EInk::UpdateTypes::UNSPECIFIED; // guaranteed update using this type + + Applet *lockOwner = nullptr; // Which system applet (if any) is preventing other applets from requesting update +}; + +}; // namespace NicheGraphics::InkHUD + +#endif \ No newline at end of file diff --git a/src/graphics/niche/Inputs/README.md b/src/graphics/niche/Inputs/README.md new file mode 100644 index 000000000..767352881 --- /dev/null +++ b/src/graphics/niche/Inputs/README.md @@ -0,0 +1,7 @@ +# NiceGraphics - Inputs + +General purpose input sources, for use with NicheGraphics UIs. + +By remaining independent, we can have tailored input sources with further complicating the code in ButtonThread and the canned messages module. + +Depending on its role, a NicheGraphics UI may or may not want to make use of the existing input broker. diff --git a/src/graphics/niche/Inputs/TwoButton.cpp b/src/graphics/niche/Inputs/TwoButton.cpp new file mode 100644 index 000000000..24b127322 --- /dev/null +++ b/src/graphics/niche/Inputs/TwoButton.cpp @@ -0,0 +1,272 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./TwoButton.h" + +#include "PowerFsm.h" +#include "sleep.h" + +using namespace NicheGraphics::Inputs; + +TwoButton::TwoButton() : concurrency::OSThread("TwoButton") +{ + // Don't start polling buttons for release immediately + // Assume they are in a "released" state at boot + OSThread::disable(); + +#ifdef ARCH_ESP32 + // Register callbacks for before and after lightsleep + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif +} + +// Get access to (or create) the singleton instance of this class +// Accessible inside the ISRs, even though we maybe shouldn't +TwoButton *TwoButton::getInstance() +{ + // Instantiate the class the first time this method is called + static TwoButton *const singletonInstance = new TwoButton; + + return singletonInstance; +} + +// Begin receiving button input +// We probably need to do this after sleep, as well as at boot +void TwoButton::start() +{ + if (buttons[0].pin != 0xFF) + attachInterrupt(buttons[0].pin, TwoButton::isrPrimary, buttons[0].activeLogic == LOW ? FALLING : RISING); + + if (buttons[1].pin != 0xFF) + attachInterrupt(buttons[1].pin, TwoButton::isrSecondary, buttons[1].activeLogic == LOW ? FALLING : RISING); +} + +// Stop receiving button input, and run custom sleep code +// Called before device sleeps. This might be power-off, or just ESP32 light sleep +// Some devices will want to attach interrupts here, for the user button to wake from sleep +void TwoButton::stop() +{ + if (buttons[0].pin != 0xFF) + detachInterrupt(buttons[0].pin); + + if (buttons[1].pin != 0xFF) + detachInterrupt(buttons[1].pin); +} + +// Configures the wiring and logic of either button +// Called when outlining your NicheGraphics implementation, in variant/nicheGraphics.cpp +void TwoButton::setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup) +{ + assert(whichButton < 2); + buttons[whichButton].pin = pin; + buttons[whichButton].activeLogic = LOW; + buttons[whichButton].mode = internalPullup ? INPUT_PULLUP : INPUT; // fix me + + pinMode(buttons[whichButton].pin, buttons[whichButton].mode); +} + +void TwoButton::setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs) +{ + assert(whichButton < 2); + buttons[whichButton].debounceLength = debounceMs; + buttons[whichButton].longpressLength = longpressMs; +} + +// Set what should happen when a button becomes pressed +// Use this to implement a "while held" behavior +void TwoButton::setHandlerDown(uint8_t whichButton, Callback onDown) +{ + assert(whichButton < 2); + buttons[whichButton].onDown = onDown; +} + +// Set what should happen when a button becomes unpressed +// Use this to implement a "While held" behavior +void TwoButton::setHandlerUp(uint8_t whichButton, Callback onUp) +{ + assert(whichButton < 2); + buttons[whichButton].onUp = onUp; +} + +// Set what should happen when a "short press" event has occurred +void TwoButton::setHandlerShortPress(uint8_t whichButton, Callback onShortPress) +{ + assert(whichButton < 2); + buttons[whichButton].onShortPress = onShortPress; +} + +// Set what should happen when a "long press" event has fired +// Note: this will occur while the button is still held +void TwoButton::setHandlerLongPress(uint8_t whichButton, Callback onLongPress) +{ + assert(whichButton < 2); + buttons[whichButton].onLongPress = onLongPress; +} + +// Handle the start of a press to the primary button +// Wakes our button thread +void TwoButton::isrPrimary() +{ + static volatile bool isrRunning = false; + + if (!isrRunning) { + isrRunning = true; + TwoButton *b = TwoButton::getInstance(); + if (b->buttons[0].state == State::REST) { + b->buttons[0].state = State::IRQ; + b->buttons[0].irqAtMillis = millis(); + b->startThread(); + } + isrRunning = false; + } +} + +// Handle the start of a press to the secondary button +// Wakes our button thread +void TwoButton::isrSecondary() +{ + static volatile bool isrRunning = false; + + if (!isrRunning) { + isrRunning = true; + TwoButton *b = TwoButton::getInstance(); + if (b->buttons[1].state == State::REST) { + b->buttons[1].state = State::IRQ; + b->buttons[1].irqAtMillis = millis(); + b->startThread(); + } + isrRunning = false; + } +} + +// Concise method to start our button thread +// Follows an ISR, listening for button release +void TwoButton::startThread() +{ + if (!OSThread::enabled) { + OSThread::setInterval(50); + OSThread::enabled = true; + } +} + +// Concise method to stop our button thread +// Called when we no longer need to poll for button release +void TwoButton::stopThread() +{ + if (OSThread::enabled) { + OSThread::disable(); + } + + // Reset both buttons manually + // Just in case an IRQ fires during the process of resetting the system + // Can occur with super rapid presses? + buttons[0].state = REST; + buttons[1].state = REST; +} + +// Our button thread +// Started by an IRQ, on either button +// Polls for button releases +// Stops when both buttons released +int32_t TwoButton::runOnce() +{ + constexpr uint8_t BUTTON_COUNT = sizeof(buttons) / sizeof(Button); + + // Allow either button to request that our thread should continue polling + bool awaitingRelease = false; + + // Check both primary and secondary buttons + for (uint8_t i = 0; i < BUTTON_COUNT; i++) { + switch (buttons[i].state) { + // No action: button has not been pressed + case REST: + break; + + // New press detected by interrupt + case IRQ: + powerFSM.trigger(EVENT_PRESS); // Tell PowerFSM that press occurred (resets sleep timer) + buttons[i].onDown(); // Inform that press has begun (possible hold behavior) + buttons[i].state = State::POLLING_UNFIRED; // Mark that button-down has been handled + awaitingRelease = true; // Mark that polling-for-release should continue + break; + + // An existing press continues + // Not held long enough to register as longpress + case POLLING_UNFIRED: { + uint32_t length = millis() - buttons[i].irqAtMillis; + + // If button released since last thread tick, + if (digitalRead(buttons[i].pin) != buttons[i].activeLogic) { + buttons[i].onUp(); // Inform that press has ended (possible release of a hold) + buttons[i].state = State::REST; // Mark that the button has reset + if (length > buttons[i].debounceLength && length < buttons[i].longpressLength) + buttons[i].onShortPress(); + } + + // If button not yet released + else { + awaitingRelease = true; // Mark that polling-for-release should continue + if (length >= buttons[i].longpressLength) { + // Raise a long press event, once + // Then continue waiting for release, to rearm + buttons[i].state = State::POLLING_FIRED; + buttons[i].onLongPress(); + } + } + break; + } + + // Button still held, but duration long enough that longpress event already fired + // Just waiting for release + case POLLING_FIRED: + // Release detected + if (digitalRead(buttons[i].pin) != buttons[i].activeLogic) { + buttons[i].state = State::REST; + buttons[i].onUp(); // Possible release of hold (in this case: *after* longpress has fired) + } + // Not yet released, keep polling + else + awaitingRelease = true; + break; + } + } + + // If both buttons are now released + // we don't need to waste cpu resources polling + // IRQ will restart this thread when we next need it + if (!awaitingRelease) + stopThread(); + + // Run this method again, or don't.. + // Use whatever behavior was previously set by stopThread() or startThread() + return OSThread::interval; +} + +#ifdef ARCH_ESP32 + +// Detach our class' interrupts before lightsleep +// Allows sleep.cpp to configure its own interrupts, which wake the device on user-button press +int TwoButton::beforeLightSleep(void *unused) +{ + stop(); + return 0; // Indicates success +} + +// Reconfigure our interrupts +// Our class' interrupts were disconnected during sleep, to allow the user button to wake the device from sleep +int TwoButton::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + start(); + + // Manually trigger the button-down ISR + // - during light sleep, our ISR is disabled + // - if light sleep ends by button press, pretend our own ISR caught it + if (cause == ESP_SLEEP_WAKEUP_GPIO) + isrPrimary(); + + return 0; // Indicates success +} + +#endif + +#endif \ No newline at end of file diff --git a/src/graphics/niche/Inputs/TwoButton.h b/src/graphics/niche/Inputs/TwoButton.h new file mode 100644 index 000000000..1e1576256 --- /dev/null +++ b/src/graphics/niche/Inputs/TwoButton.h @@ -0,0 +1,103 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +/* + +Re-usable NicheGraphics input source + +Short and Long press for up to two buttons +Interrupt driven + +*/ + +#pragma once + +#include "configuration.h" + +#include "assert.h" +#include "functional" + +#ifdef ARCH_ESP32 +#include "esp_sleep.h" // For light-sleep handling +#endif + +#include "Observer.h" + +namespace NicheGraphics::Inputs +{ + +class TwoButton : protected concurrency::OSThread +{ + public: + typedef std::function Callback; + + static TwoButton *getInstance(); // Create or get the singleton instance + void start(); // Start handling button input + void stop(); // Stop handling button input (disconnect ISRs for sleep) + void setWiring(uint8_t whichButton, uint8_t pin, bool internalPulldown = false); + void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); + void setHandlerDown(uint8_t whichButton, Callback onDown); + void setHandlerUp(uint8_t whichButton, Callback onUp); + void setHandlerShortPress(uint8_t whichButton, Callback onShortPress); + void setHandlerLongPress(uint8_t whichButton, Callback onLongPress); + + // Disconnect and reconnect interrupts for light sleep +#ifdef ARCH_ESP32 + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + + private: + // Internal state of a specific button + enum State { + REST, // Up, no activity + IRQ, // Down detected, not yet handled + POLLING_UNFIRED, // Down handled, polling for release + POLLING_FIRED, // Longpress fired, button still held + }; + + // Contains info about a specific button + // (Array of this struct below) + class Button + { + public: + // Per-button config + uint8_t pin = 0xFF; // 0xFF: unset + bool activeLogic = LOW; // Active LOW by default. Todo: remove, unused + uint8_t mode = INPUT; // Whether to use internal pull up / pull down resistors + uint32_t debounceLength = 50; // Minimum length for shortpress, in ms + uint32_t longpressLength = 500; // How long after button down to fire longpress, in ms + volatile State state = State::REST; // Internal state + volatile uint32_t irqAtMillis; // millis() when button went down + + // Per-button event callbacks + static void noop(){}; + std::function onDown = noop; + std::function onUp = noop; + std::function onShortPress = noop; + std::function onLongPress = noop; + }; + +#ifdef ARCH_ESP32 + // Get notified when lightsleep begins and ends + CallbackObserver lsObserver = CallbackObserver(this, &TwoButton::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &TwoButton::afterLightSleep); +#endif + + int32_t runOnce() override; // Timer method. Polls for button release + + void startThread(); // Start polling for release + void stopThread(); // Stop polling for release + + static void isrPrimary(); // Detect start of press + static void isrSecondary(); // Detect start of press (optional aux button) + + TwoButton(); // Constructor made private: force use of Button::instance() + + // Info about both buttons + Button buttons[2]; +}; + +}; // namespace NicheGraphics::Inputs + +#endif \ No newline at end of file diff --git a/src/graphics/niche/README.md b/src/graphics/niche/README.md new file mode 100644 index 000000000..e87464abc --- /dev/null +++ b/src/graphics/niche/README.md @@ -0,0 +1,15 @@ +# NicheGraphics + +A pattern / collection of resources for creating custom UIs, to target small groups of devices which have specific design requirements. + +For an example, see the `heltec-vision-master-e290-inkhud` platformio env. + +- platformio.ini + + - suppress default Meshtastic components (Screen, ButtonThread, etc) + - define `MESHTASTIC_INCLUDE_NICHE_GRAPHICS` + - (possibly) Edit `build_src_filter` to include our new nicheGraphics.h file + +- nicheGraphics.h + - `#include` all necessary components + - perform all setup and config inside a `setupNicheGraphics()` method diff --git a/src/main.cpp b/src/main.cpp index fe5dc4311..ebd984573 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -136,6 +136,11 @@ UdpMulticastThread *udpThread = nullptr; float tcxoVoltage = SX126X_DIO3_TCXO_VOLTAGE; // if TCXO is optional, put this here so it can be changed further down. #endif +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS +void setupNicheGraphics(); +#include "nicheGraphics.h" +#endif + using namespace concurrency; volatile static const char slipstreamTZString[] = USERPREFS_TZ_STRING; @@ -152,6 +157,9 @@ meshtastic::GPSStatus *gpsStatus = new meshtastic::GPSStatus(); // Global Node status meshtastic::NodeStatus *nodeStatus = new meshtastic::NodeStatus(); +// Global Bluetooth status +meshtastic::BluetoothStatus *bluetoothStatus = new meshtastic::BluetoothStatus(); + // Scan for I2C Devices /// The I2C address of our display (if found) @@ -903,6 +911,11 @@ void setup() // Now that the mesh service is created, create any modules setupModules(); +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + // After modules are setup, so we can observe modules + setupNicheGraphics(); +#endif + #ifdef LED_PIN // Turn LED off after boot, if heartbeat by config if (config.device.led_heartbeat_disabled) diff --git a/src/main.h b/src/main.h index f8213cd75..3b71cfeea 100644 --- a/src/main.h +++ b/src/main.h @@ -1,5 +1,6 @@ #pragma once +#include "BluetoothStatus.h" #include "GPSStatus.h" #include "NodeStatus.h" #include "PowerStatus.h" diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index d517abfc0..00d101495 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -607,6 +607,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) hasScreen = true; else hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C; +#elif MESHTASTIC_INCLUDE_NICHE_GRAPHICS // See "src/graphics/niche" + bool hasScreen = true; // Use random pin for Bluetooth pairing #else bool hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C; #endif @@ -633,10 +635,6 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.display.screen_on_secs = 30; config.display.wake_on_tap_or_motion = true; #endif -#ifdef HELTEC_VISION_MASTER_E290 - // Orient so that LoRa antenna faces up - config.display.flip_screen = true; -#endif initConfigIntervals(); } diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 2662ef0bc..ca5ac430b 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -91,7 +91,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks LOG_INFO("*** Enter passkey %d on the peer side ***", passkey); powerFSM.trigger(EVENT_BLUETOOTH_PAIR); -#if HAS_SCREEN + bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(std::to_string(passkey))); + +#if HAS_SCREEN // Todo: migrate this display code back into Screen class, and observe bluetoothStatus screen->startAlert([passkey](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { char btPIN[16] = "888888"; snprintf(btPIN, sizeof(btPIN), "%06u", passkey); @@ -127,6 +129,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks { LOG_INFO("BLE authentication complete"); + bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED)); + + // Todo: migrate this display code back into Screen class, and observe bluetoothStatus if (passkeyShowing) { passkeyShowing = false; screen->endAlert(); @@ -137,6 +142,9 @@ class NimbleBluetoothServerCallback : public NimBLEServerCallbacks { LOG_INFO("BLE disconnect"); + bluetoothStatus->updateStatus( + new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED)); + if (bluetoothPhoneAPI) { bluetoothPhoneAPI->close(); } diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index b98620f33..87d8adfa9 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -57,6 +57,9 @@ void onConnect(uint16_t conn_handle) char central_name[32] = {0}; connection->getPeerName(central_name, sizeof(central_name)); LOG_INFO("BLE Connected to %s", central_name); + + // Notify UI (or any other interested firmware components) + bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED)); } /** * Callback invoked when a connection is dropped @@ -69,6 +72,9 @@ void onDisconnect(uint16_t conn_handle, uint8_t reason) if (bluetoothPhoneAPI) { bluetoothPhoneAPI->close(); } + + // Notify UI (or any other interested firmware components) + bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED)); } void onCccd(uint16_t conn_hdl, BLECharacteristic *chr, uint16_t cccd_value) { @@ -319,7 +325,17 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke { LOG_INFO("BLE pair process started with passkey %.3s %.3s", passkey, passkey + 3); powerFSM.trigger(EVENT_BLUETOOTH_PAIR); -#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + + // Get passkey as string + // Note: possible leading zeros + std::string textkey; + for (uint8_t i = 0; i < 6; i++) + textkey += (char)passkey[i]; + + // Notify UI (or other components) of pairing event and passkey + bluetoothStatus->updateStatus(new meshtastic::BluetoothStatus(textkey)); + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) // Todo: migrate this display code back into Screen class, and observe bluetoothStatus screen->startAlert([](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -> void { char btPIN[16] = "888888"; snprintf(btPIN, sizeof(btPIN), "%06u", configuredPasskey); @@ -358,10 +374,18 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke } void NRF52Bluetooth::onPairingCompleted(uint16_t conn_handle, uint8_t auth_status) { - if (auth_status == BLE_GAP_SEC_STATUS_SUCCESS) + if (auth_status == BLE_GAP_SEC_STATUS_SUCCESS) { LOG_INFO("BLE pair success"); - else + bluetoothStatus->updateStatus( + new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED)); + } else { LOG_INFO("BLE pair failed"); + // Notify UI (or any other interested firmware components) + bluetoothStatus->updateStatus( + new meshtastic::BluetoothStatus(meshtastic::BluetoothStatus::ConnectionState::DISCONNECTED)); + } + + // Todo: migrate this display code back into Screen class, and observe bluetoothStatus screen->endAlert(); } diff --git a/src/shutdown.h b/src/shutdown.h index 9e30e772c..c2ba6f670 100644 --- a/src/shutdown.h +++ b/src/shutdown.h @@ -3,6 +3,7 @@ #include "graphics/Screen.h" #include "main.h" #include "power.h" +#include "sleep.h" #if defined(ARCH_PORTDUINO) #include "api/WiFiServerAPI.h" #include "input/LinuxInputImpl.h" @@ -13,6 +14,7 @@ void powerCommandsCheck() { if (rebootAtMsec && millis() > rebootAtMsec) { LOG_INFO("Rebooting"); + notifyReboot.notifyObservers(NULL); #if defined(ARCH_ESP32) ESP.restart(); #elif defined(ARCH_NRF52) diff --git a/src/sleep.cpp b/src/sleep.cpp index 161b6e107..2c91b0014 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -4,7 +4,6 @@ #include "GPS.h" #endif -#include "ButtonThread.h" #include "Default.h" #include "Led.h" #include "MeshRadio.h" @@ -39,9 +38,19 @@ esp_sleep_source_t wakeCause; // the reason we booted this time /// Called to ask any observers if they want to veto sleep. Return 1 to veto or 0 to allow sleep to happen Observable preflightSleep; -/// Called to tell observers we are now entering sleep and you should prepare. Must return 0 -/// notifySleep will be called for light or deep sleep, notifyDeepSleep is only called for deep sleep -Observable notifySleep, notifyDeepSleep; +/// Called to tell observers we are now entering (deep) sleep and you should prepare. Must return 0 +Observable notifyDeepSleep; + +/// Called to tell observers we are rebooting ASAP. Must return 0 +Observable notifyReboot; + +#ifdef ARCH_ESP32 +/// Called to tell observers that light sleep is about to begin +Observable notifyLightSleep; + +/// Called to tell observers that light sleep has just ended, and why it ended +Observable notifyLightSleepEnd; +#endif // deep sleep support RTC_DATA_ATTR int bootCount = 0; @@ -183,8 +192,6 @@ static void waitEnterSleep(bool skipPreflight = false) // Code that still needs to be moved into notifyObservers console->flush(); // send all our characters before we stop cpu clock setBluetoothEnable(false); // has to be off before calling light sleep - - notifySleep.notifyObservers(NULL); } void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveNodeDb = false) @@ -206,11 +213,8 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN #endif #ifdef ARCH_ESP32 - if (shouldLoraWake(msecToWake)) { - notifySleep.notifyObservers(NULL); - } else { + if (!shouldLoraWake(msecToWake)) notifyDeepSleep.notifyObservers(NULL); - } #else notifyDeepSleep.notifyObservers(NULL); #endif @@ -350,6 +354,7 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #endif waitEnterSleep(false); + notifyLightSleep.notifyObservers(NULL); // Button interrupts are detached here uint64_t sleepUsec = sleepMsec * 1000LL; @@ -385,9 +390,6 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // The enableLoraInterrupt() method is using ext0_wakeup, so we are forced to use GPIO wakeup gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); - // Have to *fully* detach the normal button-interrupts first - buttonThread->detachButtonInterrupts(); - gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL); esp_sleep_enable_gpio_wakeup(); #endif @@ -426,7 +428,6 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #ifdef BUTTON_PIN // Disable wake-on-button interrupt. Re-attach normal button-interrupts gpio_wakeup_disable(pin); - buttonThread->attachButtonInterrupts(); #endif #ifdef T_WATCH_S3 @@ -445,6 +446,8 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r #endif esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); + notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here + #ifdef BUTTON_PIN if (cause == ESP_SLEEP_WAKEUP_GPIO) { LOG_INFO("Exit light sleep gpio: btn=%d", diff --git a/src/sleep.h b/src/sleep.h index 8d3cb17e8..f780fb3c0 100644 --- a/src/sleep.h +++ b/src/sleep.h @@ -34,12 +34,20 @@ extern bool bluetoothOn; /// Called to ask any observers if they want to veto sleep. Return 1 to veto or 0 to allow sleep to happen extern Observable preflightSleep; -/// Called to tell observers we are now entering (light or deep) sleep and you should prepare. Must return 0 -extern Observable notifySleep; - /// Called to tell observers we are now entering (deep) sleep and you should prepare. Must return 0 extern Observable notifyDeepSleep; +/// Called to tell observers we are rebooting ASAP. Must return 0 +extern Observable notifyReboot; + +#ifdef ARCH_ESP32 +/// Called to tell observers that light sleep is about to begin +extern Observable notifyLightSleep; + +/// Called to tell observers that light sleep has just ended, and why it ended +extern Observable notifyLightSleepEnd; +#endif + void enableModemSleep(); #ifdef ARCH_ESP32 void enableLoraInterrupt(); diff --git a/variants/heltec_vision_master_e213/nicheGraphics.h b/variants/heltec_vision_master_e213/nicheGraphics.h new file mode 100644 index 000000000..04e36bf72 --- /dev/null +++ b/variants/heltec_vision_master_e213/nicheGraphics.h @@ -0,0 +1,115 @@ +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +// --------------------------- +#include "graphics/niche/InkHUD/WindowManager.h" + +// Applets +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" + +// #include "graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h" +// #include "graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h" + +// Shared NicheGraphics components +// -------------------------------- +#include "graphics/niche/Inputs/TwoButton.h" +#include "graphics/niche/drivers/Eink/LCMEN2R13EFC1.h" + +#include "graphics/niche/Fonts/FreeSans6pt7b.h" +#include "graphics/niche/Fonts/FreeSans6pt8bCyrillic.h" +#include + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // SPI + // ----------------------------- + + // Display is connected to HSPI + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + // E-Ink Driver + // ----------------------------- + + // Use E-Ink driver + Drivers::EInk *driver = new Drivers::LCMEN213EFC1; + driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES); + + // InkHUD + // ---------------------------- + + InkHUD::WindowManager *windowManager = InkHUD::WindowManager::getInstance(); + + // Set the driver + windowManager->setDriver(driver); + + // Set how many FAST updates per FULL update + // Set how unhealthy additional FAST updates beyond this number are + windowManager->setDisplayResilience(10, 1.5); + + // Prepare fonts + InkHUD::AppletFont largeFont(FreeSans9pt7b); + InkHUD::AppletFont smallFont(FreeSans6pt7b); + /* + // Font localization demo: Cyrillic + InkHUD::AppletFont smallFont(FreeSans6pt8bCyrillic); + smallFont.addSubstitutionsWin1251(); + */ + InkHUD::Applet::setDefaultFonts(largeFont, smallFont); + + // Init settings, and customize defaults + InkHUD::settings.userTiles.maxCount = 2; // How many tiles can the display handle? + InkHUD::settings.rotation = 3; // 270 degrees clockwise + InkHUD::settings.userTiles.count = 1; // One tile only by default, keep things simple for new users + InkHUD::settings.optionalMenuItems.nextTile = false; // Behavior handled by aux button instead + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + // Optional arguments for defaults: + // - is activated? + // - is autoshown? + // - is foreground on a specific tile (index)? + windowManager->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown + windowManager->addApplet("DMs", new InkHUD::DMApplet); + windowManager->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); + windowManager->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); + windowManager->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + windowManager->addApplet("Recents List", new InkHUD::RecentsListApplet); + windowManager->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 + // windowManager->addApplet("Basic", new InkHUD::BasicExampleApplet); + // windowManager->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + + // Start running window manager + windowManager->begin(); + + // Buttons + // -------------------------- + + Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // Shared NicheGraphics component + constexpr uint8_t MAIN_BUTTON = 0; + constexpr uint8_t AUX_BUTTON = 1; + + // Setup the main user button + buttons->setWiring(MAIN_BUTTON, BUTTON_PIN); + buttons->setHandlerShortPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonShort(); }); + buttons->setHandlerLongPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonLong(); }); + + // Setup the aux button + // Bonus feature of VME213 + buttons->setWiring(AUX_BUTTON, BUTTON_PIN_SECONDARY); + buttons->setHandlerShortPress(AUX_BUTTON, []() { InkHUD::WindowManager::getInstance()->nextTile(); }); + buttons->start(); +} + +#endif \ No newline at end of file diff --git a/variants/heltec_vision_master_e213/platformio.ini b/variants/heltec_vision_master_e213/platformio.ini index cc6f283b5..62317e248 100644 --- a/variants/heltec_vision_master_e213/platformio.ini +++ b/variants/heltec_vision_master_e213/platformio.ini @@ -5,6 +5,7 @@ build_flags = ${esp32s3_base.build_flags} -Ivariants/heltec_vision_master_e213 -DHELTEC_VISION_MASTER_E213 + -DUSE_EINK -DEINK_DISPLAY_MODEL=GxEPD2_213_FC1 -DEINK_WIDTH=250 -DEINK_HEIGHT=122 @@ -16,4 +17,23 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/meshtastic/GxEPD2#b202ebfec6a4821e098cf7a625ba0f6f2400292d lewisxhe/PCF8563_Library@^1.0.1 +upload_speed = 115200 + +; Using experimental InkHUD UI (work in progress) +[platformio] +extra_configs = src/graphics/niche/InkHUD/PlatformioConfig.ini +[env:heltec-vision-master-e213-inkhud] +extends = esp32s3_base, inkhud +board = heltec_vision_master_e213 +build_src_filter = + ${esp32_base.build_src_filter} + ${inkhud.build_src_filter} +build_flags = + ${esp32s3_base.build_flags} + ${inkhud.build_flags} + -I variants/heltec_vision_master_e213 + -D HELTEC_VISION_MASTER_E213 +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot intead of AdafruitGFX + ${esp32s3_base.lib_deps} upload_speed = 115200 \ No newline at end of file diff --git a/variants/heltec_vision_master_e213/variant.h b/variants/heltec_vision_master_e213/variant.h index 386df6fcf..49b8e91f5 100644 --- a/variants/heltec_vision_master_e213/variant.h +++ b/variants/heltec_vision_master_e213/variant.h @@ -8,7 +8,6 @@ #define I2C_SCL SCL // Display (E-Ink) -#define USE_EINK #define PIN_EINK_CS 5 #define PIN_EINK_BUSY 1 #define PIN_EINK_DC 2 diff --git a/variants/heltec_vision_master_e290/nicheGraphics.h b/variants/heltec_vision_master_e290/nicheGraphics.h new file mode 100644 index 000000000..fb4654189 --- /dev/null +++ b/variants/heltec_vision_master_e290/nicheGraphics.h @@ -0,0 +1,129 @@ +/* + +Most of the Meshtastic firmware uses preprocessor macros throughout the code to support different hardware variants. +NicheGraphics attempts a different approach: + +Per-device config takes place in this setupNicheGraphics() method +(And a small amount in platformio.ini) + +This file sets up InkHUD for Heltec VM-E290. +Different NicheGraphics UIs and different hardware variants will each have their own setup procedure. + +*/ + +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +// --------------------------- +#include "graphics/niche/InkHUD/WindowManager.h" + +// Applets +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" + +// #include "graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h" +// #include "graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h" + +// Shared NicheGraphics components +// -------------------------------- +#include "graphics/niche/Inputs/TwoButton.h" +#include "graphics/niche/drivers/Eink/DEPG0290BNS800.h" + +#include "graphics/niche/Fonts/FreeSans6pt7b.h" +#include "graphics/niche/Fonts/FreeSans6pt8bCyrillic.h" +#include + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // SPI + // ----------------------------- + + // Display is connected to HSPI + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + // E-Ink Driver + // ----------------------------- + + // Use E-Ink driver + Drivers::EInk *driver = new Drivers::DEPG0290BNS800; + driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY); + + // InkHUD + // ---------------------------- + + InkHUD::WindowManager *windowManager = InkHUD::WindowManager::getInstance(); + + // Set the driver + windowManager->setDriver(driver); + + // Set how many FAST updates per FULL update + // Set how unhealthy additional FAST updates beyond this number are + windowManager->setDisplayResilience(7, 1.5); + + // Prepare fonts + InkHUD::AppletFont largeFont(FreeSans9pt7b); + InkHUD::AppletFont smallFont(FreeSans6pt7b); + /* + // Font localization demo: Cyrillic + InkHUD::AppletFont smallFont(FreeSans6pt8bCyrillic); + smallFont.addSubstitutionsWin1251(); + */ + InkHUD::Applet::setDefaultFonts(largeFont, smallFont); + + // Init settings, and customize defaults + InkHUD::settings.userTiles.maxCount = 2; // How many tiles can the display handle? + InkHUD::settings.rotation = 1; // 90 degrees clockwise + InkHUD::settings.userTiles.count = 1; // One tile only by default, keep things simple for new users + InkHUD::settings.optionalMenuItems.nextTile = false; // Behavior handled by aux button instead + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + // Optional arguments for defaults: + // - is activated? + // - is autoshown? + // - is foreground on a specific tile (index)? + windowManager->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown + windowManager->addApplet("DMs", new InkHUD::DMApplet); + windowManager->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); + windowManager->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); + windowManager->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + windowManager->addApplet("Recents List", new InkHUD::RecentsListApplet); + windowManager->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 + // windowManager->addApplet("Basic", new InkHUD::BasicExampleApplet); + // windowManager->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + + // Start running window manager + windowManager->begin(); + + // Buttons + // -------------------------- + + Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // Shared NicheGraphics component + constexpr uint8_t MAIN_BUTTON = 0; + constexpr uint8_t AUX_BUTTON = 1; + + // Setup the main user button + buttons->setWiring(MAIN_BUTTON, BUTTON_PIN); + buttons->setHandlerShortPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonShort(); }); + buttons->setHandlerLongPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonLong(); }); + + // Setup the aux button + // Bonus feature of VME290 + buttons->setWiring(AUX_BUTTON, BUTTON_PIN_SECONDARY); + buttons->setHandlerShortPress(AUX_BUTTON, []() { InkHUD::WindowManager::getInstance()->nextTile(); }); + + buttons->start(); +} + +#endif \ No newline at end of file diff --git a/variants/heltec_vision_master_e290/platformio.ini b/variants/heltec_vision_master_e290/platformio.ini index 06804e4f2..323b5abab 100644 --- a/variants/heltec_vision_master_e290/platformio.ini +++ b/variants/heltec_vision_master_e290/platformio.ini @@ -1,14 +1,17 @@ +; Using the original screen class [env:heltec-vision-master-e290] extends = esp32s3_base board = heltec_vision_master_e290 build_flags = ${esp32s3_base.build_flags} -I variants/heltec_vision_master_e290 + -D DISPLAY_FLIP_SCREEN ; Orient so the LoRa antenna faces up -D HELTEC_VISION_MASTER_E290 -D BUTTON_CLICK_MS=200 -D EINK_DISPLAY_MODEL=GxEPD2_290_BN8 -D EINK_WIDTH=296 -D EINK_HEIGHT=128 + -D USE_EINK -D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk -D EINK_LIMIT_FASTREFRESH=10 ; How many consecutive fast-refreshes are permitted -D EINK_HASQUIRK_GHOSTING ; Display model is identified as "prone to ghosting" @@ -18,4 +21,23 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/meshtastic/GxEPD2#448c8538129fde3d02a7cb5e6fc81971ad92547f lewisxhe/PCF8563_Library@^1.0.1 +upload_speed = 115200 + +; Using experimental InkHUD UI (work in progress) +[platformio] +extra_configs = src/graphics/niche/InkHUD/PlatformioConfig.ini +[env:heltec-vision-master-e290-inkhud] +extends = esp32s3_base, inkhud +board = heltec_vision_master_e290 +build_src_filter = + ${esp32_base.build_src_filter} + ${inkhud.build_src_filter} +build_flags = + ${esp32s3_base.build_flags} + ${inkhud.build_flags} + -I variants/heltec_vision_master_e290 + -D HELTEC_VISION_MASTER_E290 +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot intead of AdafruitGFX + ${esp32s3_base.lib_deps} upload_speed = 115200 \ No newline at end of file diff --git a/variants/heltec_vision_master_e290/variant.h b/variants/heltec_vision_master_e290/variant.h index 299186549..9d6041539 100644 --- a/variants/heltec_vision_master_e290/variant.h +++ b/variants/heltec_vision_master_e290/variant.h @@ -8,7 +8,6 @@ #define I2C_SCL SCL // Display (E-Ink) -#define USE_EINK #define PIN_EINK_CS 3 #define PIN_EINK_BUSY 6 #define PIN_EINK_DC 4 diff --git a/variants/heltec_wireless_paper/nicheGraphics.h b/variants/heltec_wireless_paper/nicheGraphics.h new file mode 100644 index 000000000..6a1fa5910 --- /dev/null +++ b/variants/heltec_wireless_paper/nicheGraphics.h @@ -0,0 +1,111 @@ +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +// --------------------------- +#include "graphics/niche/InkHUD/WindowManager.h" + +// Applets +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" + +// #include "graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h" +// #include "graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h" + +// Shared NicheGraphics components +// -------------------------------- +#include "graphics/niche/Inputs/TwoButton.h" +#include "graphics/niche/drivers/Eink/LCMEN2R13EFC1.h" + +#include "graphics/niche/Fonts/FreeSans6pt7b.h" +#include "graphics/niche/Fonts/FreeSans6pt8bCyrillic.h" +#include + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // SPI + // ----------------------------- + + // Display is connected to HSPI + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + // E-Ink Driver + // ----------------------------- + + // Use E-Ink driver + Drivers::EInk *driver = new Drivers::LCMEN213EFC1; + driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES); + + // InkHUD + // ---------------------------- + + InkHUD::WindowManager *windowManager = InkHUD::WindowManager::getInstance(); + + // Set the driver + windowManager->setDriver(driver); + + // Set how many FAST updates per FULL update + // Set how unhealthy additional FAST updates beyond this number are + windowManager->setDisplayResilience(10, 1.5); + + // Prepare fonts + InkHUD::AppletFont largeFont(FreeSans9pt7b); + InkHUD::AppletFont smallFont(FreeSans6pt7b); + /* + // Font localization demo: Cyrillic + InkHUD::AppletFont smallFont(FreeSans6pt8bCyrillic); + smallFont.addSubstitutionsWin1251(); + */ + InkHUD::Applet::setDefaultFonts(largeFont, smallFont); + + // Init settings, and customize defaults + InkHUD::settings.userTiles.maxCount = 2; // How many tiles can the display handle? + InkHUD::settings.rotation = 3; // 270 degrees clockwise + InkHUD::settings.userTiles.count = 1; // One tile only by default, keep things simple for new users + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + // Optional arguments for defaults: + // - is activated? + // - is autoshown? + // - is foreground on a specific tile (index)? + windowManager->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown + windowManager->addApplet("DMs", new InkHUD::DMApplet); + windowManager->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); + windowManager->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); + windowManager->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + windowManager->addApplet("Recents List", new InkHUD::RecentsListApplet); + windowManager->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 + // windowManager->addApplet("Basic", new InkHUD::BasicExampleApplet); + // windowManager->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + + // Start running window manager + windowManager->begin(); + + // Buttons + // -------------------------- + + Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // Shared NicheGraphics component + constexpr uint8_t MAIN_BUTTON = 0; + + // Setup the main user button + buttons->setWiring(MAIN_BUTTON, BUTTON_PIN); + buttons->setHandlerShortPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonShort(); }); + buttons->setHandlerLongPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonLong(); }); + + // No aux button on this board + + buttons->start(); +} + +#endif \ No newline at end of file diff --git a/variants/heltec_wireless_paper/platformio.ini b/variants/heltec_wireless_paper/platformio.ini index a7045b182..59ef7c228 100644 --- a/variants/heltec_wireless_paper/platformio.ini +++ b/variants/heltec_wireless_paper/platformio.ini @@ -1,3 +1,4 @@ +; Using the original screen class [env:heltec-wireless-paper] extends = esp32s3_base board = heltec_wifi_lora_32_V3 @@ -8,6 +9,7 @@ build_flags = -D EINK_DISPLAY_MODEL=GxEPD2_213_FC1 -D EINK_WIDTH=250 -D EINK_HEIGHT=122 + -D USE_EINK -D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk -D EINK_LIMIT_FASTREFRESH=10 ; How many consecutive fast-refreshes are permitted -D EINK_BACKGROUND_USES_FAST ; (Optional) Use FAST refresh for both BACKGROUND and RESPONSIVE, until a limit is reached. @@ -16,4 +18,24 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/meshtastic/GxEPD2#b202ebfec6a4821e098cf7a625ba0f6f2400292d lewisxhe/PCF8563_Library@^1.0.1 +upload_speed = 115200 + +; Using experimental InkHUD UI (work in progress) +[platformio] +extra_configs = src/graphics/niche/InkHUD/PlatformioConfig.ini +[env:heltec-wireless-paper-inkhud] +extends = esp32s3_base, inkhud +board = heltec_wifi_lora_32_V3 +board_level = extra +build_src_filter = + ${esp32_base.build_src_filter} + ${inkhud.build_src_filter} +build_flags = + ${esp32s3_base.build_flags} + ${inkhud.build_flags} + -I variants/heltec_wireless_paper + -D HELTEC_WIRELESS_PAPER +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot intead of AdafruitGFX + ${esp32s3_base.lib_deps} upload_speed = 115200 \ No newline at end of file diff --git a/variants/heltec_wireless_paper/variant.h b/variants/heltec_wireless_paper/variant.h index fe8f391df..0385945e6 100644 --- a/variants/heltec_wireless_paper/variant.h +++ b/variants/heltec_wireless_paper/variant.h @@ -6,7 +6,6 @@ #define I2C_SCL SCL // Display (E-Ink) -#define USE_EINK #define PIN_EINK_CS 4 #define PIN_EINK_BUSY 7 #define PIN_EINK_DC 5 diff --git a/variants/t-echo/nicheGraphics.h b/variants/t-echo/nicheGraphics.h new file mode 100644 index 000000000..65229dc04 --- /dev/null +++ b/variants/t-echo/nicheGraphics.h @@ -0,0 +1,126 @@ +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +// --------------------------- +#include "graphics/niche/InkHUD/WindowManager.h" + +// Applets +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" + +// #include "graphics/niche/InkHUD/Applets/Examples/BasicExample/BasicExampleApplet.h" +// #include "graphics/niche/InkHUD/Applets/Examples/NewMsgExample/NewMsgExampleApplet.h" + +// Shared NicheGraphics components +// -------------------------------- +#include "graphics/niche/Drivers/Backlight/LatchingBacklight.h" +#include "graphics/niche/Drivers/Eink/GDEY0154D67.h" +#include "graphics/niche/Inputs/TwoButton.h" + +#include "graphics/niche/Fonts/FreeSans6pt7b.h" +#include "graphics/niche/Fonts/FreeSans6pt8bCyrillic.h" +#include + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // SPI + // ----------------------------- + + // For NRF52 platforms, SPI pins are defined in variant.h, not passed to begin() + SPIClass *inkSPI = &SPI1; + inkSPI->begin(); + + // Driver + // ----------------------------- + + // Use E-Ink driver + Drivers::EInk *driver = new Drivers::GDEY0154D67; + driver->begin(inkSPI, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES); + + // InkHUD + // ---------------------------- + + InkHUD::WindowManager *windowManager = InkHUD::WindowManager::getInstance(); + + // Set the driver + windowManager->setDriver(driver); + + // Set how many FAST updates per FULL update + // Set how unhealthy additional FAST updates beyond this number are + windowManager->setDisplayResilience(20, 1.5); + + // Prepare fonts + InkHUD::AppletFont largeFont(FreeSans9pt7b); + InkHUD::AppletFont smallFont(FreeSans6pt7b); + /* + // Font localization demo: Cyrillic + InkHUD::AppletFont smallFont(FreeSans6pt8bCyrillic); + smallFont.addSubstitutionsWin1251(); + */ + InkHUD::Applet::setDefaultFonts(largeFont, smallFont); + + // Init settings, and customize defaults + // Values ignored individually if found saved to flash + InkHUD::settings.userTiles.maxCount = 2; // Two applets side-by-side + InkHUD::settings.rotation = 3; // 270 degrees clockwise + InkHUD::settings.optionalFeatures.batteryIcon = true; // Device definitely has a battery + InkHUD::settings.optionalMenuItems.backlight = true; // Until proven (by touch) that user still has the capacitive button + + // Setup backlight + // Note: AUX button behavior configured further down + Drivers::LatchingBacklight *backlight = Drivers::LatchingBacklight::getInstance(); + backlight->setPin(PIN_EINK_EN); + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + windowManager->addApplet("All Messages", new InkHUD::AllMessageApplet, true, true); // Activated, autoshown + windowManager->addApplet("DMs", new InkHUD::DMApplet); + windowManager->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); + windowManager->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); + windowManager->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + windowManager->addApplet("Recents List", new InkHUD::RecentsListApplet); + windowManager->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 + // windowManager->addApplet("Basic", new InkHUD::BasicExampleApplet); + // windowManager->addApplet("NewMsg", new InkHUD::NewMsgExampleApplet); + + // Start running window manager + windowManager->begin(); + + // Buttons + // -------------------------- + + Inputs::TwoButton *buttons = Inputs::TwoButton::getInstance(); // Shared NicheGraphics component + constexpr uint8_t MAIN_BUTTON = 0; + constexpr uint8_t TOUCH_BUTTON = 1; + + // Setup the main user button + buttons->setWiring(MAIN_BUTTON, BUTTON_PIN, LOW); + buttons->setHandlerShortPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonShort(); }); + buttons->setHandlerLongPress(MAIN_BUTTON, []() { InkHUD::WindowManager::getInstance()->handleButtonLong(); }); + + // Setup the capacitive touch button + // - short: momentary backlight + // - long: latch backlight on + buttons->setWiring(TOUCH_BUTTON, PIN_BUTTON_TOUCH, LOW); + buttons->setTiming(TOUCH_BUTTON, 50, 5000); // 5 seconds before latch - limited by T-Echo's capacitive touch IC + buttons->setHandlerDown(TOUCH_BUTTON, [backlight]() { + backlight->peek(); + InkHUD::settings.optionalMenuItems.backlight = false; // We've proved user still has the button. No need for menu entry. + }); + buttons->setHandlerLongPress(TOUCH_BUTTON, [backlight]() { backlight->latch(); }); + buttons->setHandlerShortPress(TOUCH_BUTTON, [backlight]() { backlight->off(); }); + + buttons->start(); +} + +#endif \ No newline at end of file diff --git a/variants/t-echo/platformio.ini b/variants/t-echo/platformio.ini index ce58c0b88..e0e26fe6a 100644 --- a/variants/t-echo/platformio.ini +++ b/variants/t-echo/platformio.ini @@ -1,4 +1,4 @@ -; First prototype eink/nrf52840/sx1262 device +; Using original screen class [env:t-echo] extends = nrf52840_base board = t-echo @@ -12,6 +12,7 @@ build_flags = ${nrf52840_base.build_flags} -Ivariants/t-echo -DEINK_DISPLAY_MODEL=GxEPD2_154_D67 -DEINK_WIDTH=200 -DEINK_HEIGHT=200 + -DUSE_EINK -DUSE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk -DEINK_LIMIT_FASTREFRESH=20 ; How many consecutive fast-refreshes are permitted -DEINK_BACKGROUND_USES_FAST ; (Optional) Use FAST refresh for both BACKGROUND and RESPONSIVE, until a limit is reached. @@ -21,4 +22,26 @@ lib_deps = ${nrf52840_base.lib_deps} https://github.com/meshtastic/GxEPD2#55f618961db45a23eff0233546430f1e5a80f63a lewisxhe/PCF8563_Library@^1.0.1 -;upload_protocol = fs \ No newline at end of file +;upload_protocol = fs + +; Using experimental InkHUD UI (work in progress) +[platformio] +extra_configs = src/graphics/niche/InkHUD/PlatformioConfig.ini +[env:t-echo-inkhud] +extends = nrf52840_base, inkhud +board = t-echo +board_check = true +debug_tool = jlink +build_flags = + ${nrf52840_base.build_flags} + ${inkhud.build_flags} + -I variants/t-echo + -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" +build_src_filter = + ${nrf52_base.build_src_filter} + ${inkhud.build_src_filter} + +<../variants/t-echo> +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot intead of AdafruitGFX + ${nrf52840_base.lib_deps} + lewisxhe/PCF8563_Library@^1.0.1 \ No newline at end of file diff --git a/variants/t-echo/variant.h b/variants/t-echo/variant.h index 365dfd804..38b7f4743 100644 --- a/variants/t-echo/variant.h +++ b/variants/t-echo/variant.h @@ -162,8 +162,6 @@ External serial flash WP25R1635FZUIL0 #define PIN_POWER_EN (0 + 12) // #define PIN_POWER_EN1 (0 + 13) -#define USE_EINK - #define PIN_SPI1_MISO \ (32 + 7) // FIXME not really needed, but for now the SPI code requires something to be defined, pick an used GPIO #define PIN_SPI1_MOSI PIN_EINK_MOSI