Add automatic red and black night mode for color screens

Default to one hour after sunset through sunrise, using fresh cached GPS or recent direct RF neighbor locations. Provide saved CLI and WebConfig controls with a small integer solar approximation and no additional framebuffer.
This commit is contained in:
mikecarper committed 2026-10-25 14:41:14 -07:00
1 parent 94139c79be
commit fb73e3afac
49 files changed
+4072 -2421

No files matched your search

+4
View File
@@ -1175,6 +1175,9 @@ jobs:
python3 -B test/test_backlight_poll_rollover.py -v
python3 -B test/test_rgb_led_rollover.py -v
python3 -B test/test_display_power.py
python3 -B test/test_display_night_mode.py -v
python3 -B test/test_display_night_location.py -v
python3 -B test/test_display_night_rendering.py -v
python3 -B test/test_t114_display_detection.py -v
python3 -B test/test_rc32_display_detection.py -v
python3 -B test/test_wireless_tracker_display_detection.py -v
@@ -1206,6 +1209,7 @@ jobs:
python3 -B test/test_webconfig_ui_runtime.py
python3 -B test/test_webconfig_lost_reply.py -v
python3 -B test/test_webconfig_cpu_frequency.py -v
python3 -B test/test_display_night_webconfig.py -v
MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_web_ui.py -v
MESHCORE_REQUIRE_ROOM_BROWSER=1 python3 -B test/test_room_mail_web_ui.py -v
+1
View File
@@ -316,6 +316,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local | Local | Local |
| Display | [`get/set display.mode`; `display.timeout`; `display.usb.mode`; `display.usb.timeout`](cli_commands.md#set-display-modes-and-timeouts) | Display-equipped roles; pairing modes require BLE Companion | Feature | Feature | Feature | Feature | Feature |
| Night display | [`get/set display.night`; `display.night.delay`](cli_commands.md#set-display-modes-and-timeouts) | Color displays only; auto uses clock and location | Feature | Feature | Feature | Feature | Feature |
| Display | [`get/set display.flip`](cli_commands.md#set-mqtt-observer-display-flip) | MQTT observer with supported display driver | No | No | No | Feature | No |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
+19
View File
@@ -1326,6 +1326,25 @@ saved settings. The WebConfig **Display** card exposes the same controls:
Modes are `off`, `on`, `button`, `pairing`, `button-pairing`, and `automatic`.
Pairing modes are available only on builds supporting BLE pairing.
Color displays also support saved night-mode controls in the CLI and WebConfig:
| Setting | CLI | Default |
| --- | --- | --- |
| Night mode | `get/set display.night auto\|on\|off` | `auto` |
| Minutes after sunset | `get/set display.night.delay -180..180` | `60` |
Night mode uses red and black only. `auto` starts after sunset plus the selected delay and ends at sunrise. Negative
delays start before sunset. Automatic mode requires a valid clock and location;
use `on` or `off` to choose manually. Monochrome and headless boards do not
support these controls.
The device's cached GPS fix takes priority. Without GPS, it averages the latest
16 distinct neighbors with coordinates heard directly over LoRa in the last
12 hours. Relayed, bridged and imported locations do not count. With no usable
position, automatic mode keeps normal colors. The small integer approximation
uses UTC and longitude, so it needs neither a time zone nor daylight-saving
configuration. Screen brightness and screen-off timers still work normally.
All display-equipped Companion builds show the screen at boot in the default
`button` or `button-pairing` mode, on battery or USB power. It stays on for the
selected profile's timeout (15 minutes on e-paper, 15 seconds elsewhere).
@@ -95,6 +95,10 @@ MESH_COMPANION_HELP_BEGIN
" memory\r\n"
#endif
" get/set display.rotation <0|90|180|270>\r\n"
#ifdef MESHCORE_HAS_COLOR_DISPLAY
" get/set display.night <auto|on|off>\r\n"
" get/set display.night.delay <-180..180> (minutes after sunset; default 60)\r\n"
#endif
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
" get/set display.controller <auto|ssd1306|sh1106> (saved; restart to apply)\r\n"
#endif
+17 -1
View File
@@ -5,6 +5,7 @@
#endif
#include <helpers/LedSettings.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/ui/DisplayNightLocation.h>
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
#include <helpers/ui/OLEDControllerSettings.h>
#endif
@@ -1892,14 +1893,24 @@ void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type,
BaseChatMesh::onGroupDataRecv(packet, type, key, data, len);
}
#ifdef COMPANION_MESH_CLOCK_SYNC
#if defined(COMPANION_MESH_CLOCK_SYNC) || defined(MESHCORE_HAS_COLOR_DISPLAY)
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) {
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!isLocalContactImport(packet)) {
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
app_data_len, millis());
}
#endif
BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
#endif
}
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
_clock_sync.observeGroupPacket(packet);
}
@@ -11562,6 +11573,11 @@ void MyMesh::loop() {
);
#endif
BaseChatMesh::loop();
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!mesh::morseTimingActive(_radio)) {
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
}
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
if (!mesh::morseTimingActive(_radio)) _clock_sync.loop();
#endif
+4 -1
View File
@@ -6,6 +6,7 @@
#include "CompanionFeatures.h"
#include <helpers/CompanionReader.h>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/ui/DisplayBuildFlags.h>
/*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 14
@@ -481,10 +482,12 @@ protected:
bool allowFloodRetry(const mesh::Packet* packet) const override;
uint8_t getFloodRetryMaxPathLength(const mesh::Packet* packet) const override;
uint8_t getFloodRetryMaxAttempts(const mesh::Packet* packet) const override;
#ifdef COMPANION_MESH_CLOCK_SYNC
#if defined(COMPANION_MESH_CLOCK_SYNC) || defined(MESHCORE_HAS_COLOR_DISPLAY)
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) override;
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
void onGroupPacketRecv(mesh::Packet* packet) override;
#endif
+10
View File
@@ -1,4 +1,5 @@
#include "MyMesh.h"
#include <helpers/ui/DisplayNightLocation.h>
#if FLOOD_ADVERT_RTC_FAST_SLOTS
alignas(4) RTC_FAST_ATTR static mesh::FloodAdvertLimiter::Entry
@@ -2951,6 +2952,10 @@ static bool isShare(const mesh::Packet *packet) {
void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp,
const uint8_t *app_data, size_t app_data_len) {
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
app_data_len, millis());
#endif
mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl
// Mesh calls this hook only after verifying the advert's Ed25519 signature.
@@ -13315,6 +13320,11 @@ void MyMesh::loop() {
// Check radio FIRST to ensure we don't miss incoming packets
// MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here
mesh::Mesh::loop();
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!mesh::morseTimingActive(_radio)) {
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
}
#endif
#if MESH_USB_CONSOLE_COOPERATIVE
servicePendingSerialOutput();
#endif
+8 -1
View File
@@ -875,8 +875,15 @@ void UITask::loop() {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const bool screen_was_on = _display->isOn();
#endif
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
if (_display->isOn()) resetRadioProfileDisplayPage();
if (_display->isOn()
#ifdef MESHCORE_HAS_COLOR_DISPLAY
&& !screen_was_on
#endif
) resetRadioProfileDisplayPage();
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
+10
View File
@@ -1,4 +1,5 @@
#include "MyMesh.h"
#include <helpers/ui/DisplayNightLocation.h>
#include <helpers/DefaultPasswordWarning.h>
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) \
&& HELTEC_TFT_RUNTIME_ROTATION == 1
@@ -2396,6 +2397,10 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) {
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) {
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
app_data_len, millis());
#endif
mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
@@ -4091,6 +4096,11 @@ void MyMesh::loop() {
// Check radio FIRST to ensure we don't miss incoming packets
// MQTT processing can take time, so we prioritize radio reception
mesh::Mesh::loop();
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!mesh::morseTimingActive(_radio)) {
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
}
#endif
#if MESH_USB_CONSOLE_COOPERATIVE
servicePendingSerialOutput();
#endif
+6
View File
@@ -526,7 +526,13 @@ void UITask::loop() {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const bool screen_was_on = _display->isOn();
#endif
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
#ifdef MESHCORE_HAS_COLOR_DISPLAY
if (screen_was_on != _display->isOn())
#endif
resetRadioProfileDisplayPage();
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
+10
View File
@@ -1,4 +1,5 @@
#include "SensorMesh.h"
#include <helpers/ui/DisplayNightLocation.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/ClientACLCLI.h>
#include <helpers/ClientACLResponse.h>
@@ -851,6 +852,10 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
void SensorMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) {
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
app_data_len, millis());
#endif
mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
#if MESH_ENABLE_SENSOR_TRACKER
@@ -1519,6 +1524,11 @@ void SensorMesh::loop() {
#endif
_cli.loop();
mesh::Mesh::loop();
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!mesh::morseTimingActive(_radio)) {
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
}
#endif
// Do not consume sensor/advert/write deadlines during timed Morse capture.
// USB command service and the physical radio continue on every pass.
if (mesh::morseTimingActive(_radio)) return;
+6
View File
@@ -234,7 +234,13 @@ void UITask::loop() {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio());
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const bool screen_was_on = _display->isOn();
#endif
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
#ifdef MESHCORE_HAS_COLOR_DISPLAY
if (screen_was_on != _display->isOn())
#endif
resetRadioProfileDisplayPage();
_last_refresh = millis() - 1000u;
}
+2
View File
@@ -62,6 +62,8 @@ build_src_filter =
+<helpers/*.cpp>
+<helpers/ui/DisplayDriver.cpp>
+<helpers/ui/DisplayPowerSettings.cpp>
+<helpers/ui/DisplayNightMode.cpp>
+<helpers/ui/DisplayNightLocation.cpp>
+<helpers/ui/DisplayRotationSettings.cpp>
+<helpers/ui/OLEDControllerSettings.cpp>
+<helpers/ota/OtaTinf.c>
+3
View File
@@ -108,6 +108,9 @@ class BaseChatMesh : public mesh::Mesh {
int sendContactPacket(const ContactInfo& recipient, mesh::Packet* packet, uint32_t& est_timeout);
protected:
bool isLocalContactImport(const mesh::Packet* packet) const {
return packet == _pendingLoopback;
}
void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4);
// Autonomous replies have independent ACK tracking and must preserve the
// application/terminal send's shared timeout.
+1
View File
@@ -25,6 +25,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
"repeat",
"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout",
"display.inbox", "display.rotation",
"display.night", "display.night.delay",
"advert.interval", "flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
File diff suppressed because it is too large. Load diff
+8
View File
@@ -13,6 +13,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#include <ArduinoJson.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/ui/DisplayBuildFlags.h>
#include <helpers/ui/DisplayNightMode.h>
#include <helpers/RegulatorSettings.h>
#include <Preferences.h>
#include <esp_wifi.h>
@@ -2443,6 +2444,13 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
screen["usb_mode"] = mesh::ui::displayModeName(display_prefs.usb.mode);
screen["usb_timeout"] = display_prefs.usb.seconds;
screen["pairing"] = mesh::ui::displayPairingSupported();
#ifdef MESHCORE_HAS_COLOR_DISPLAY
screen["night_supported"] = true;
const uint8_t night_mode = mesh::ui::displayNightMode();
screen["night"] = night_mode == 0 ? "off" : night_mode == 1 ? "on" : "auto";
screen["night_delay"] = mesh::ui::displayNightDelayMinutes();
screen["night_active"] = mesh::ui::displayNightActive();
#endif
screen["rotation_supported"] = node.display_rotation_supported;
if (node.display_rotation_supported) {
screen["rotation"] = node.display_rotation_degrees;
+22
View File
@@ -1,6 +1,7 @@
#pragma once
#include "DisplayDriver.h"
#include "DisplayNightMode.h"
// Shared default palette for displays that can render real colour. Monochrome
// OLED and e-paper drivers deliberately keep their own black/white palettes:
@@ -35,6 +36,27 @@ constexpr ColorVal ACCENT = rgb565(64, 176, 240);
constexpr ColorVal TOUCH_OUTLINE = rgb565(255, 255, 0);
constexpr ColorVal TOUCH_PRESSED = rgb565(0, 255, 0);
constexpr ColorVal NIGHT_BACKGROUND = 0x0000;
constexpr ColorVal NIGHT_FOREGROUND = 0xF800;
inline bool isBackground(ColorVal color) {
return color == 0 || color == UIColor::window_bkg
|| color == UIColor::title_bkg || color == UIColor::popup_bkg
|| color == WINDOW_BACKGROUND || color == TITLE_BACKGROUND
|| color == POPUP_BACKGROUND;
}
// Leave the saved daytime palette intact, including variant-specific themes.
// Mapping at the drawing boundary also covers raw colors and touch overlays.
inline ColorVal render(ColorVal color) {
return displayNightActive()
? (isBackground(color) ? NIGHT_BACKGROUND : NIGHT_FOREGROUND) : color;
}
inline ColorVal background(ColorVal color) {
return displayNightActive() ? NIGHT_BACKGROUND : color;
}
enum IndexedColor : uint8_t {
INDEX_BACKGROUND = 0,
INDEX_TEXT = 1,
+19
View File
@@ -11,6 +11,13 @@
#define MESHCORE_EINK_DISPLAY_CLASS_GxEPDDisplay 1
#define MESHCORE_EINK_DISPLAY_CLASS_E213Display 1
#define MESHCORE_EINK_DISPLAY_CLASS_E290Display 1
#define MESHCORE_COLOR_DISPLAY_CLASS_ST7735Display 1
#define MESHCORE_COLOR_DISPLAY_CLASS_ST7789Display 1
#define MESHCORE_COLOR_DISPLAY_CLASS_ST7789LCDDisplay 1
#define MESHCORE_COLOR_DISPLAY_CLASS_NV3001BDisplay 1
#define MESHCORE_COLOR_DISPLAY_CLASS_LGFXDisplay 1
#define MESHCORE_COLOR_DISPLAY_CLASS_SCIndicatorDisplay 1
#define MESHCORE_COLOR_DISPLAY_CLASS_WioTrackerL2Display 1
#define MESHCORE_DISPLAY_TOKEN_JOIN_INNER(prefix, name) prefix##name
#define MESHCORE_DISPLAY_TOKEN_JOIN(prefix, name) \
MESHCORE_DISPLAY_TOKEN_JOIN_INNER(prefix, name)
@@ -26,6 +33,11 @@
#define MESHCORE_HAS_EINK_DISPLAY 1
#endif
#if defined(DISPLAY_CLASS) \
&& MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_COLOR_DISPLAY_CLASS_, DISPLAY_CLASS)
#define MESHCORE_HAS_COLOR_DISPLAY 1
#endif
// These drivers back physically compact panels where the message-channel
// selector consumes too much of the useful text area. Do not infer this from
// DisplayDriver::height(): the 160x80 ST7735 is still physically compact.
@@ -46,6 +58,13 @@
#undef MESHCORE_DISPLAY_TOKEN_JOIN
#undef MESHCORE_DISPLAY_TOKEN_JOIN_INNER
#undef MESHCORE_COLOR_DISPLAY_CLASS_WioTrackerL2Display
#undef MESHCORE_COLOR_DISPLAY_CLASS_SCIndicatorDisplay
#undef MESHCORE_COLOR_DISPLAY_CLASS_LGFXDisplay
#undef MESHCORE_COLOR_DISPLAY_CLASS_NV3001BDisplay
#undef MESHCORE_COLOR_DISPLAY_CLASS_ST7789LCDDisplay
#undef MESHCORE_COLOR_DISPLAY_CLASS_ST7789Display
#undef MESHCORE_COLOR_DISPLAY_CLASS_ST7735Display
#undef MESHCORE_SMALL_DISPLAY_CLASS_U8g2Display
#undef MESHCORE_SMALL_DISPLAY_CLASS_ST7735Display
#undef MESHCORE_SMALL_DISPLAY_CLASS_SSD1306Display
+6 -1
View File
@@ -1,11 +1,16 @@
#include "DisplayDriver.h"
#include "DisplayNightMode.h"
#include <Arduino.h>
bool DisplayDriver::servicePower(bool usb_power, bool app_connected, bool pairing) {
_power_policy.update(mesh::ui::displayPowerPrefs(), usb_power, app_connected, pairing, millis());
const bool desired = _notification_power < 0 ? _power_policy.on() : _notification_power != 0;
const bool changed = isOn() != desired;
bool changed = isOn() != desired;
if (changed) { if (desired) turnOn(); else turnOff(); }
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const bool night = mesh::ui::displayNightActive();
if (_night_seen != night) { _night_seen = night; changed = true; }
#endif
return changed;
}
+4 -1
View File
@@ -18,6 +18,9 @@ class DisplayDriver {
int _w, _h;
mesh::ui::DisplayPowerPolicy _power_policy;
int8_t _notification_power = -1;
#ifdef MESHCORE_HAS_COLOR_DISPLAY
bool _night_seen = false;
#endif
protected:
DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; }
@@ -55,7 +58,7 @@ public:
virtual void forceFullRefresh() {} // next refresh will be full for eink
virtual void turnOn() = 0;
virtual void turnOff() = 0;
// Returns whether the panel state changed, allowing cached UIs to redraw.
// Returns whether power or palette changed, allowing cached UIs to redraw.
bool servicePower(bool usb_power, bool app_connected = false, bool pairing = false);
bool wake(mesh::ui::DisplayWake reason);
void dismiss();
+50
View File
@@ -0,0 +1,50 @@
#include "DisplayNightLocation.h"
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
#include <Mesh.h>
#include <helpers/AdvertDataHelpers.h>
#include <helpers/SensorManager.h>
#include "DisplayNightMode.h"
namespace mesh {
namespace ui {
namespace {
DisplayNightLocationCache locations;
uint32_t last_refresh_ms = 0;
bool refreshed = false;
}
void observeDisplayNightAdvert(const mesh::Packet* packet,
const mesh::Identity& identity,
const mesh::Identity& self,
const uint8_t* app_data, size_t app_data_len,
uint32_t now_ms) {
if (packet == nullptr || packet->radio_local || packet->forwarded
|| app_data_len > MAX_ADVERT_DATA_SIZE
|| identity.matches(self)
|| packet->getPathHashCount() != 0
|| (packet->hasTransportCodes() && packet->transport_codes[0] == 0
&& packet->transport_codes[1] == 0)) return;
// Each role invokes this from its verified-advert callback, before contact
// admission. Shared or relayed contact data cannot refresh local RF age.
AdvertDataParser advert(app_data, app_data_len);
if (!advert.isValid() || !advert.hasLatLon()) return;
uint32_t prefix;
memcpy(&prefix, identity.pub_key, sizeof(prefix));
locations.observe(prefix, advert.getIntLat(), advert.getIntLon(), now_ms);
}
void refreshDisplayNightLocation(const SensorManager& sensors,
uint32_t utc, uint32_t now_ms) {
if (refreshed && static_cast<uint32_t>(now_ms - last_refresh_ms) < 15000UL) return;
refreshed = true;
last_refresh_ms = now_ms;
int32_t latitude_e6, longitude_e6;
const bool has_location = readDisplayNightLocation(
locations, sensors, now_ms, latitude_e6, longitude_e6);
updateDisplayNightMode(utc, has_location, latitude_e6, longitude_e6);
}
} // namespace ui
} // namespace mesh
#endif
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <math.h>
#include "DisplayBuildFlags.h"
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
class SensorManager;
namespace mesh {
class Packet;
class Identity;
namespace ui {
// Solar timing needs only an approximate position. Keep one observation per
// identity prefix, at roughly 1 km resolution, rather than copying contacts.
class DisplayNightLocationCache {
struct Entry {
uint32_t identity;
uint32_t heard_at;
int16_t latitude_e2;
int16_t longitude_e2;
bool empty() const { return latitude_e2 == 0 && longitude_e2 == 0; }
void clear() { latitude_e2 = longitude_e2 = 0; }
};
static_assert(sizeof(Entry) == 12, "Night location observations must stay compact");
Entry entries[16] = {};
static int16_t quantize(int32_t value) {
return (value + (value < 0 ? -5000 : 5000)) / 10000;
}
static int32_t meanE6(int32_t sum, uint8_t count) {
// Split the multiplication to keep the whole average in 32-bit math.
return (sum / count) * 10000 + ((sum % count) * 10000) / count;
}
public:
static constexpr uint32_t MAX_AGE_MS = 12UL * 60UL * 60UL * 1000UL;
static bool valid(int32_t latitude_e6, int32_t longitude_e6) {
return latitude_e6 >= -90000000L && latitude_e6 <= 90000000L
&& longitude_e6 >= -180000000L && longitude_e6 <= 180000000L
&& (latitude_e6 != 0 || longitude_e6 != 0);
}
bool observe(uint32_t identity, int32_t latitude_e6, int32_t longitude_e6,
uint32_t now_ms) {
if (!valid(latitude_e6, longitude_e6)) return false;
const int16_t latitude_e2 = quantize(latitude_e6);
const int16_t longitude_e2 = quantize(longitude_e6);
if (latitude_e2 == 0 && longitude_e2 == 0) return false;
Entry* selected = &entries[0];
uint32_t oldest_age = 0;
for (auto& entry : entries) {
if (!entry.empty() && entry.identity == identity) {
selected = &entry;
break;
}
const uint32_t age = now_ms - entry.heard_at;
if (entry.empty() || age > oldest_age) {
selected = &entry;
oldest_age = entry.empty() ? UINT32_MAX : age;
}
}
selected->identity = identity;
selected->heard_at = now_ms;
selected->latitude_e2 = latitude_e2;
selected->longitude_e2 = longitude_e2;
return true;
}
bool average(uint32_t now_ms, int32_t& latitude_e6, int32_t& longitude_e6) {
int32_t latitude_sum = 0, longitude_sum = 0, anchor = 0;
uint8_t count = 0;
for (auto& entry : entries) {
if (entry.empty()) continue;
if (static_cast<uint32_t>(now_ms - entry.heard_at) > MAX_AGE_MS) {
entry.clear();
continue;
}
if (count == 0) anchor = entry.longitude_e2;
int32_t longitude = entry.longitude_e2;
if (longitude - anchor > 18000) longitude -= 36000;
else if (longitude - anchor < -18000) longitude += 36000;
latitude_sum += entry.latitude_e2;
longitude_sum += longitude;
++count;
}
latitude_e6 = longitude_e6 = 0;
if (count == 0) return false;
latitude_e6 = meanE6(latitude_sum, count);
longitude_e6 = meanE6(longitude_sum, count);
if (longitude_e6 > 180000000L) longitude_e6 -= 360000000L;
else if (longitude_e6 <= -180000000L) longitude_e6 += 360000000L;
return true;
}
};
template<class Sensors>
bool readDisplayNightLocation(DisplayNightLocationCache& cache,
const Sensors& sensors, uint32_t now_ms,
int32_t& latitude_e6, int32_t& longitude_e6) {
// Retire expired neighbors even while GPS supplies the preferred position.
const bool has_neighbors = cache.average(now_ms, latitude_e6, longitude_e6);
double latitude = 0, longitude = 0;
if (sensors.getCachedGpsPosition(latitude, longitude)
&& isfinite(latitude) && isfinite(longitude)
&& latitude >= -90.0 && latitude <= 90.0
&& longitude >= -180.0 && longitude <= 180.0) {
const int32_t gps_latitude = static_cast<int32_t>(latitude * 1000000.0
+ (latitude < 0 ? -0.5 : 0.5));
const int32_t gps_longitude = static_cast<int32_t>(longitude * 1000000.0
+ (longitude < 0 ? -0.5 : 0.5));
if (DisplayNightLocationCache::valid(gps_latitude, gps_longitude)) {
latitude_e6 = gps_latitude;
longitude_e6 = gps_longitude;
return true;
}
}
return has_neighbors;
}
void observeDisplayNightAdvert(const mesh::Packet* packet,
const mesh::Identity& identity,
const mesh::Identity& self,
const uint8_t* app_data, size_t app_data_len,
uint32_t now_ms);
void refreshDisplayNightLocation(const SensorManager& sensors,
uint32_t utc, uint32_t now_ms);
} // namespace ui
} // namespace mesh
#endif
+168
View File
@@ -0,0 +1,168 @@
#include "DisplayNightSettings.h"
#ifdef MESHCORE_HAS_COLOR_DISPLAY
#include "DisplayNightPolicy.h"
#include <helpers/CheckedSettingsFile.h>
#include <stdio.h>
#include <string.h>
namespace mesh { namespace ui {
namespace {
FILESYSTEM* settings_fs = nullptr;
constexpr char path[] = "/display_night";
constexpr char backup[] = "/display_night.bak";
uint8_t mode = NightAuto;
int16_t delay_minutes = 60;
bool active = false, has_position = false;
uint32_t observed_utc = 0, evaluated_minute = UINT32_MAX;
int32_t latitude = 0, longitude = 0;
void evaluate() {
active = mode == NightOn || (mode == NightAuto && has_position
&& night_policy::active(observed_utc, latitude, longitude, delay_minutes));
}
void encode(uint8_t selected, int16_t delay, uint8_t (&data)[8]) {
data[0] = 'D'; data[1] = 'N'; data[2] = 1; data[3] = selected;
const uint16_t encoded = uint16_t(delay + 180);
data[4] = encoded & 255; data[5] = encoded >> 8;
data[6] = data[7] = 0;
for (unsigned i = 0; i < 7; ++i) data[7] ^= data[i];
}
bool read(const char* name, uint8_t (&data)[8]) {
if (!mesh::storage::readFixedSettingsFile(settings_fs, name, data, sizeof(data))) return false;
const uint16_t encoded = uint16_t(data[4]) | (uint16_t(data[5]) << 8);
uint8_t expected[8];
encode(data[3], int16_t(encoded) - 180, expected);
return data[3] <= NightAuto && encoded <= 360
&& memcmp(data, expected, sizeof(data)) == 0;
}
bool recoverBackup() {
uint8_t current[8], saved[8];
if (read(path, current) || !read(backup, saved)) return true;
// Never rotate a corrupt primary over the only valid record on a later save.
if (settings_fs->exists(path) && !settings_fs->remove(path)) return false;
if (!settings_fs->rename(backup, path)) return false;
return read(path, current) && !memcmp(current, saved, sizeof(current));
}
bool save(uint8_t selected, int16_t delay) {
if (!settings_fs || !recoverBackup()) return false;
uint8_t data[8];
encode(selected, delay, data);
#if defined(NRF52_PLATFORM)
const bool committed = mesh::storage::writeCheckedSettingsFile(settings_fs, path, data, sizeof(data))
== mesh::AtomicFileWriter::Outcome::Committed;
if (committed) settings_fs->remove(backup);
return committed;
#else
constexpr char temporary[] = "/display_night.tmp";
settings_fs->remove(temporary);
if (settings_fs->exists(temporary)) return false;
#if defined(STM32_PLATFORM)
File file(*settings_fs);
if (!file.open(temporary, FILE_O_WRITE)) return false;
#elif defined(RP2040_PLATFORM)
File file = settings_fs->open(temporary, "w");
#else
File file = settings_fs->open(temporary, "w", true);
#endif
if (!file) return false;
const bool written = file.write(data, sizeof(data)) == sizeof(data);
file.close();
uint8_t checked[8];
if (!written || !read(temporary, checked) || memcmp(data, checked, sizeof(data))) return false;
if (settings_fs->exists(backup) && !settings_fs->remove(backup)) return false;
if (settings_fs->exists(path) && !settings_fs->rename(path, backup)) return false;
if (!settings_fs->rename(temporary, path)) {
if (settings_fs->exists(backup)) settings_fs->rename(backup, path);
return false;
}
if (!read(path, checked) || memcmp(data, checked, sizeof(data))) return false;
settings_fs->remove(backup);
return true;
#endif
}
} // namespace
uint8_t displayNightMode() { return mode; }
int16_t displayNightDelayMinutes() { return delay_minutes; }
bool displayNightActive() { return active; }
void loadDisplayNightSettings(FILESYSTEM* fs) {
settings_fs = fs;
mode = NightAuto;
delay_minutes = 60;
if (fs) recoverBackup();
uint8_t data[8];
if (read(path, data) || read(backup, data)) {
mode = data[3];
delay_minutes = int16_t(uint16_t(data[4]) | (uint16_t(data[5]) << 8)) - 180;
}
evaluated_minute = UINT32_MAX;
evaluate();
}
void updateDisplayNightMode(uint32_t utc, bool has_location, int32_t latitude_e6,
int32_t longitude_e6) {
observed_utc = utc;
const bool usable = has_location && night_policy::validLocation(latitude_e6, longitude_e6);
const bool changed = usable != has_position;
has_position = usable;
latitude = latitude_e6;
longitude = longitude_e6;
if (!changed && evaluated_minute == utc / 60) return;
evaluated_minute = utc / 60;
evaluate();
}
bool handleDisplayNightCommand(const char* command, char* reply, size_t capacity) {
const bool get = strncmp(command, "get ", 4) == 0;
if (!get && strncmp(command, "set ", 4) != 0) return false;
const char* key = command + 4;
bool delay = false;
size_t length = 13;
if (!strncmp(key, "display.night.delay", 19)) { delay = true; length = 19; }
else if (strncmp(key, "display.night", 13)) return false;
if (key[length] && key[length] != ' ') return false;
const char* value = key + length;
static const char* const names[] = {"off", "on", "auto"};
if (get) {
if (*value) snprintf(reply, capacity, "Error: unexpected argument");
else if (delay) snprintf(reply, capacity, "> %d", int(delay_minutes));
else snprintf(reply, capacity, "> %s", names[mode]);
return true;
}
while (*value == ' ') ++value;
uint8_t selected = mode;
int16_t minutes = delay_minutes;
if (delay) {
const bool negative = *value == '-';
if (negative || *value == '+') ++value;
unsigned magnitude = 0;
bool valid = *value != 0;
for (; valid && *value; ++value) {
valid = *value >= '0' && *value <= '9';
if (valid) { magnitude = magnitude * 10 + (*value - '0'); valid = magnitude <= 180; }
}
if (!valid) { snprintf(reply, capacity, "Error: delay must be -180..180 minutes"); return true; }
minutes = negative ? -int(magnitude) : int(magnitude);
} else {
for (selected = NightOff; selected <= NightAuto; ++selected)
if (!strcmp(value, names[selected])) break;
if (selected > NightAuto) { snprintf(reply, capacity, "Error: use auto|on|off"); return true; }
}
if (!save(selected, minutes)) snprintf(reply, capacity, "Error: night settings save failed");
else {
mode = selected;
delay_minutes = minutes;
evaluate();
snprintf(reply, capacity, "OK");
}
return true;
}
} }
#endif
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "DisplayBuildFlags.h"
#include <stddef.h>
#include <stdint.h>
namespace mesh { namespace ui {
// These values are also the small on-disk mode encoding.
enum DisplayNightMode : uint8_t { NightOff = 0, NightOn = 1, NightAuto = 2 };
#ifdef MESHCORE_HAS_COLOR_DISPLAY
uint8_t displayNightMode();
int16_t displayNightDelayMinutes();
bool displayNightActive();
bool handleDisplayNightCommand(const char* command, char* reply, size_t capacity);
// UTC and position are observations only; refreshing them never writes flash.
void updateDisplayNightMode(uint32_t utc, bool has_location,
int32_t latitude_e6 = 0, int32_t longitude_e6 = 0);
#else
inline uint8_t displayNightMode() { return NightOff; }
inline int16_t displayNightDelayMinutes() { return 60; }
inline bool displayNightActive() { return false; }
inline bool handleDisplayNightCommand(const char*, char*, size_t) { return false; }
inline void updateDisplayNightMode(uint32_t, bool, int32_t = 0, int32_t = 0) {}
#endif
} }
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <stdint.h>
namespace mesh { namespace ui { namespace night_policy {
// A seven-entry quarter-wave table avoids floating-point trig or an astronomy
// library. Linear interpolation is sufficient for a display's dusk schedule.
inline int32_t sine(int degrees10) {
static const uint16_t values[] = {0, 259, 500, 707, 866, 966, 1000};
degrees10 %= 3600;
if (degrees10 < 0) degrees10 += 3600;
const bool negative = degrees10 > 1800;
if (negative) degrees10 -= 1800;
if (degrees10 > 900) degrees10 = 1800 - degrees10;
const unsigned index = degrees10 / 150;
int32_t value = values[index];
if (index < 6) value += ((values[index + 1] - value) * (degrees10 % 150) + 75) / 150;
return negative ? -value : value;
}
inline bool validLocation(int32_t latitude_e6, int32_t longitude_e6) {
return latitude_e6 >= -90000000 && latitude_e6 <= 90000000
&& longitude_e6 >= -180000000 && longitude_e6 <= 180000000;
}
inline bool active(uint32_t utc, int32_t latitude_e6, int32_t longitude_e6,
int16_t delay_minutes) {
if (utc < 1577836800UL || !validLocation(latitude_e6, longitude_e6)) return false;
// Longitude supplies local solar time, so neither a time zone nor DST is
// needed. Omitting the equation of time costs at most roughly 16 minutes.
int solar_minute = int(utc % 86400UL / 60) + longitude_e6 / 250000;
uint32_t day = utc / 86400UL - 10957UL; // days since 2000-01-01
if (solar_minute < 0) { solar_minute += 1440; --day; }
else if (solar_minute >= 1440) { solar_minute -= 1440; ++day; }
unsigned year = 2000;
for (;;) {
const unsigned length = 365 + (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
if (day < length) break;
day -= length;
++year;
}
const int declination = (234 * sine((int(day) - 80) * 3600 / 365)) / 1000;
const int latitude = latitude_e6 / 100000;
const int32_t numerator = -15000 - sine(latitude) * sine(declination);
const int32_t denominator = sine(latitude + 900) * sine(declination + 900);
// Include the conventional approximately -0.83 degree sunrise altitude.
// Polar day/night has no crossing and must not divide by a vanishing cosine.
if (numerator <= -denominator) return false;
if (numerator >= denominator) return true;
int low = 0, high = 1800;
while (low < high) {
const int middle = (low + high) / 2;
if (sine(middle + 900) * denominator / 1000 > numerator) low = middle + 1;
else high = middle;
}
const int half_day = (low * 4 + 5) / 10;
const int dawn = 720 - half_day;
const int dusk = 720 + half_day;
// Work in the solar day beginning at sunrise. This handles a positive
// sunset delay crossing midnight without turning night mode on too early.
int since_dawn = solar_minute - dawn;
if (since_dawn < 0) since_dawn += 1440;
const int start = dusk - dawn + delay_minutes;
if (start <= 0) return true;
return start < 1440 && since_dawn >= start;
}
} } }
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <helpers/IdentityStore.h>
#include "DisplayNightMode.h"
namespace mesh { namespace ui {
#ifdef MESHCORE_HAS_COLOR_DISPLAY
void loadDisplayNightSettings(FILESYSTEM* fs);
#else
inline void loadDisplayNightSettings(FILESYSTEM*) {}
#endif
} }
+4
View File
@@ -1,5 +1,7 @@
#include "DisplayPowerSettings.h"
#include "DisplayBuildFlags.h"
#include "DisplayNightMode.h"
#include "DisplayNightSettings.h"
#include <helpers/CheckedSettingsFile.h>
#include <stdio.h>
@@ -94,6 +96,7 @@ bool savePrefs(const DisplayPowerPrefs& prefs) {
}
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot) {
loadDisplayNightSettings(fs);
#ifdef MESHCORE_HAS_REAL_DISPLAY
settings_fs = fs;
#else
@@ -141,6 +144,7 @@ void migrateLegacyDisplayTimeout(uint16_t seconds) {
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size,
bool inbox_supported) {
if (handleDisplayNightCommand(command, reply, reply_size)) return true;
#ifndef MESHCORE_HAS_REAL_DISPLAY
(void)inbox_supported;
if ((command[0] != 'g' && command[0] != 's')
+1
View File
@@ -2,6 +2,7 @@
#include <helpers/IdentityStore.h>
#include "DisplayPowerPolicy.h"
#include "DisplayBuildFlags.h"
namespace mesh { namespace ui {
+55 -3
View File
@@ -132,7 +132,7 @@ bool LGFXDisplay::begin() {
display->setRotation(DISPLAY_ROTATION);
display->setBrightness(DISPLAY_BRIGHTNESS);
display->setColorDepth(8);
display->setTextColor(TFT_WHITE);
display->setTextColor(mesh::ui::color_theme::render(TFT_WHITE));
buffer.setColorDepth(UI_BUFFER_COLOR_DEPTH);
// The RGB scanout framebuffer must live in PSRAM. Keep the UI sprite in
@@ -228,6 +228,7 @@ void LGFXDisplay::turnOff() {
void LGFXDisplay::clear() {
// display->clearDisplay();
applyNightPalette();
buffer.clearDisplay();
_presentedEmojiOverlayCount = 0;
_hasLastFrame = false;
@@ -238,7 +239,15 @@ void LGFXDisplay::startFrame(ColorVal bkg) {
// display->getScanLine();
_emojiOverlayCount = 0;
_hasTransparentEmojiPixels = false;
_color = renderColor(bkg);
const bool night = mesh::ui::displayNightActive();
if (_nightFrame != night) {
_nightFrame = night;
_hasLastFrame = false;
_presentedEmojiOverlayCount = 0;
display->fillScreen(mesh::ui::color_theme::background(UIColor::window_bkg));
}
applyNightPalette();
_color = night ? renderColor(UIColor::window_bkg) : renderColor(bkg);
buffer.setBaseColor(_color);
buffer.fillScreen(_color);
setColor(UIColor::primary_txt);
@@ -380,7 +389,12 @@ bool LGFXDisplay::drawQrCode(const char* text, int x, int y, int size) {
const int used_size = module_size * (qr.size + quiet_zone * 2);
const int left = x * _coordinateScale + (scaled_size - used_size) / 2;
const int top = y * _coordinateScale + (scaled_size - used_size) / 2;
const uint32_t light = renderColor(UIColor::primary_txt);
uint32_t light = renderColor(UIColor::primary_txt);
#if UI_BUFFER_COLOR_DEPTH >= 8
// These QR locals use LovyanGFX's uint32 RGB888 overload. The ordinary
// drawing state is signed RGB565; preserve existing daytime QR values.
if (mesh::ui::displayNightActive()) light = mesh::ui::color_theme::rgb888(static_cast<ColorVal>(light));
#endif
const uint32_t dark = renderColor(UIColor::window_bkg);
buffer.fillRect(x * _coordinateScale, y * _coordinateScale,
@@ -539,6 +553,14 @@ bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) {
}
uint32_t LGFXDisplay::renderColor(ColorVal color) const {
if (mesh::ui::displayNightActive()) {
#if UI_BUFFER_COLOR_DEPTH < 8
return mesh::ui::color_theme::isBackground(color)
? mesh::ui::color_theme::INDEX_BACKGROUND : mesh::ui::color_theme::INDEX_TEXT;
#else
return mesh::ui::color_theme::render(color);
#endif
}
#if UI_BUFFER_COLOR_DEPTH < 8
using namespace mesh::ui::color_theme;
if (color == UIColor::window_bkg) return INDEX_BACKGROUND;
@@ -557,6 +579,17 @@ uint32_t LGFXDisplay::renderColor(ColorVal color) const {
#endif
}
void LGFXDisplay::applyNightPalette() {
#if UI_BUFFER_COLOR_DEPTH < 8
const bool night = mesh::ui::displayNightActive();
// Smooth glyph blending can choose any palette entry, not only the two
// explicit drawing indices. Keep every possible output red or black.
for (unsigned i = 0; i < 16; ++i) {
buffer.setPaletteColor(i, night ? (i ? 0xFF0000u : 0x000000u) : UI_PALETTE[i]);
}
#endif
}
bool LGFXDisplay::configurePalette() {
#if UI_BUFFER_COLOR_DEPTH < 8
return buffer.createPalette(UI_PALETTE,
@@ -681,6 +714,25 @@ int32_t LGFXDisplay::queueEmoji(lgfx::v1::LGFXBase* gfx,
|| _emojiAtlas.glyph((uint16_t)codepoint) == nullptr) {
return 0;
}
if (mesh::ui::displayNightActive()) {
// Keep only the opaque silhouette; no full-color overlay may bypass the
// red/black canvas. Work directly from the existing atlas without a copy.
const uint8_t* pixels = _emojiAtlas.glyph((uint16_t)codepoint);
const int source_width = _emojiAtlas.width();
const int source_height = _emojiAtlas.height();
const int32_t red = renderColor(UIColor::primary_txt);
for (int row = 0; row < source_height; ++row) {
const int top = y + row * font_height / source_height;
const int bottom = y + (row + 1) * font_height / source_height;
for (int col = 0; col < source_width; ++col) {
if (pixels[row * source_width + col] == _emojiAtlas.transparent()) continue;
const int left = x + col * font_height / source_width;
const int right = x + (col + 1) * font_height / source_width;
buffer.fillRect(left, top, right - left, bottom - top, red);
}
}
return font_height;
}
if (_emojiOverlayCount >= MAX_EMOJI_OVERLAYS) return font_height;
EmojiOverlay& overlay = _emojiOverlays[_emojiOverlayCount++];
+2
View File
@@ -44,6 +44,7 @@ protected:
bool _hasTransparentEmojiPixels = false;
uint32_t _lastFrameHash = 0;
bool _hasLastFrame = false;
bool _nightFrame = false;
int _logicalTextSize = 1;
bool _compactText = false;
uint8_t _coordinateScale = UI_COORD_SCALE;
@@ -52,6 +53,7 @@ protected:
String mapText(const char* str) const;
uint32_t renderColor(ColorVal color) const;
bool configurePalette();
void applyNightPalette();
uint32_t frameHash() const;
bool wasEmojiPresented(const EmojiOverlay& overlay) const;
void drawEmojiMask(int32_t x, int32_t y, int32_t size,
+5 -5
View File
@@ -421,7 +421,7 @@ bool NV3001BDisplay::begin() {
is_on = true;
color = 0x0000;
fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT);
color = 0xffff;
color = mesh::ui::color_theme::render(0xffff);
text_size = 1;
cursor_x = 0;
cursor_y = 0;
@@ -452,15 +452,15 @@ void NV3001BDisplay::turnOff() {
void NV3001BDisplay::clear() {
uint16_t saved = color;
color = UIColor::window_bkg;
color = mesh::ui::color_theme::background(UIColor::window_bkg);
fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT);
color = saved;
}
void NV3001BDisplay::startFrame(ColorVal bkg) {
color = bkg;
color = mesh::ui::color_theme::background(bkg);
fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT);
color = UIColor::primary_txt;
color = mesh::ui::color_theme::render(UIColor::primary_txt);
text_size = 1;
cursor_x = 0;
cursor_y = 0;
@@ -471,7 +471,7 @@ void NV3001BDisplay::setTextSize(int sz) {
}
void NV3001BDisplay::setColor(ColorVal c) {
color = c;
color = mesh::ui::color_theme::render(c);
}
void NV3001BDisplay::setCursor(int x, int y) {
+5 -4
View File
@@ -1,4 +1,5 @@
#include "ST7735Display.h"
#include "ColorTheme.h"
#include "../UsbLogging.h"
#if defined(NRF52_PLATFORM) && defined(MESH_NRF52_FLASH_TRIM) && MESH_NRF52_FLASH_TRIM
#include "Nrf52TftFontTrim.h"
@@ -512,7 +513,7 @@ bool ST7735Display::begin() {
return false;
}
sprite->fillScreen(ST77XX_BLACK);
sprite->setTextColor(curr_color = ST77XX_WHITE);
sprite->setTextColor(curr_color = mesh::ui::color_theme::render(ST77XX_WHITE));
}
if (!spriteReady()) {
@@ -671,8 +672,8 @@ void ST7735Display::clear() {
void ST7735Display::startFrame(ColorVal bkg) {
if (!spriteReady()) return;
sprite->fillScreen(bkg);
sprite->setTextColor(curr_color = UIColor::primary_txt);
sprite->fillScreen(mesh::ui::color_theme::background(bkg));
sprite->setTextColor(curr_color = mesh::ui::color_theme::render(UIColor::primary_txt));
sprite->setFreeFont();
sprite->setTextSize(1); // This one affects size of Please wait... message
sprite->setTextWrap(false);
@@ -690,7 +691,7 @@ int ST7735Display::textLineHeight() {
void ST7735Display::setColor(ColorVal c) {
if (!spriteReady()) return;
curr_color = c;
curr_color = mesh::ui::color_theme::render(c);
sprite->setTextColor(curr_color);
}
+6 -2
View File
@@ -1,6 +1,7 @@
#ifdef ST7789
#include "ST7789Display.h"
#include "ColorTheme.h"
#if defined(HELTEC_T114) && defined(T114_DISPLAY_AUTODETECT) && T114_DISPLAY_AUTODETECT
#include "T114DisplayProbe.h"
@@ -159,8 +160,11 @@ void ST7789Display::setTextSize(int sz) {
void ST7789Display::setColor(ColorVal c) {
if (!canDraw()) return;
_color = c;
display.setColor((OLEDDISPLAY_COLOR)_color);
display.setRGB(_color == OLEDDISPLAY_COLOR::WHITE ? ST77XX_WHITE : ST77XX_BLACK);
display.setColor(mesh::ui::displayNightActive()
? (mesh::ui::color_theme::isBackground(c) ? OLEDDISPLAY_COLOR::BLACK : OLEDDISPLAY_COLOR::WHITE)
: (OLEDDISPLAY_COLOR)_color);
display.setRGB(mesh::ui::displayNightActive() ? mesh::ui::color_theme::NIGHT_FOREGROUND
: _color == OLEDDISPLAY_COLOR::WHITE ? ST77XX_WHITE : ST77XX_BLACK);
}
void ST7789Display::setCursor(int x, int y) {
+5 -3
View File
@@ -331,7 +331,7 @@ bool ST7789LCDDisplay::begin() {
display.setSPISpeed(40e6);
display.fillScreen(ST77XX_BLACK);
display.setTextColor(ST77XX_WHITE);
display.setTextColor(mesh::ui::color_theme::render(ST77XX_WHITE));
display.setTextSize(1);
display.setTextWrap(false);
display.cp437(true); // Use full 256 char 'Code Page 437' font
@@ -400,6 +400,7 @@ void ST7789LCDDisplay::clear() {
}
void ST7789LCDDisplay::startFrame(ColorVal bkg) {
bkg = mesh::ui::color_theme::background(bkg);
#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION)
if (!_isOn) return;
#endif
@@ -409,14 +410,14 @@ void ST7789LCDDisplay::startFrame(ColorVal bkg) {
_frame_active = true;
serviceRender(true);
_canvas->fillScreen(bkg);
_canvas->setTextColor(_color = UIColor::primary_txt);
_canvas->setTextColor(_color = mesh::ui::color_theme::render(UIColor::primary_txt));
_canvas->setTextSize(1);
_canvas->cp437(true);
return;
}
#endif
display.fillScreen(bkg);
display.setTextColor(_color = UIColor::primary_txt);
display.setTextColor(_color = mesh::ui::color_theme::render(UIColor::primary_txt));
display.setTextSize(1);
display.cp437(true); // Use full 256 char 'Code Page 437' font
}
@@ -435,6 +436,7 @@ void ST7789LCDDisplay::setTextSize(int sz) {
}
void ST7789LCDDisplay::setColor(ColorVal c) {
c = mesh::ui::color_theme::render(c);
#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION)
if (!_isOn) return;
#endif
+4 -1
View File
@@ -105,6 +105,7 @@ class ST7789Spi : public OLEDDisplay {
SPIClass * _spi;
SPISettings _spiSettings;
uint16_t _RGB=0xFFFF;
uint16_t _lastRGB=0xFFFF;
uint8_t _buffheight;
public:
/* pass _cs as -1 to indicate "do not use CS pin", for cases where it is hard wired low */
@@ -157,6 +158,8 @@ class ST7789Spi : public OLEDDisplay {
void display(void) {
#ifdef OLEDDISPLAY_DOUBLE_BUFFER
const bool tint_changed = _lastRGB != _RGB;
_lastRGB = _RGB;
uint16_t minBoundY = UINT16_MAX;
uint16_t maxBoundY = 0;
@@ -172,7 +175,7 @@ class ST7789Spi : public OLEDDisplay {
for (x = 0; x < displayWidth; x++) {
//Serial.printf("x %d y %d\r\n",x,y);
uint16_t pos = x + y * displayWidth;
if (buffer[pos] != buffer_back[pos]) {
if (buffer[pos] != buffer_back[pos] || tint_changed) {
minBoundY = min(minBoundY, y);
maxBoundY = max(maxBoundY, y);
minBoundX = min(minBoundX, x);
+1
View File
@@ -6,6 +6,7 @@
#include <cstring>
#include <vector>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/ColorTheme.h>
#define pgm_read_byte(p) (*(const uint8_t*)(p))
#define PROGMEM
#define HSPI 1
+1
View File
@@ -4,6 +4,7 @@
#include <helpers/ui/ST7735Display.h>
#include <helpers/ui/SmallMessageText.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/ColorTheme.h>
ColorVal UIColor::window_bkg = 0, UIColor::title_bkg = 0, UIColor::title_txt = 1;
ColorVal UIColor::primary_txt = 1, UIColor::secondary_txt = 1, UIColor::warning_txt = 1;
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Run the night location cache and verified-advert filter on the host."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
MESH = r'''
#pragma once
#include <stdint.h>
#include <string.h>
#define MAX_ADVERT_DATA_SIZE 32
namespace mesh {
class Identity {
public:
uint8_t pub_key[32] = {};
Identity(uint32_t prefix = 0) { memcpy(pub_key, &prefix, sizeof(prefix)); }
bool matches(const Identity& other) const {
return memcmp(pub_key, other.pub_key, sizeof(pub_key)) == 0;
}
};
class Packet {
public:
bool radio_local = false, forwarded = false, transport = false;
uint16_t transport_codes[2] = {};
uint8_t hops = 0;
uint8_t getPathHashCount() const { return hops; }
bool hasTransportCodes() const { return transport; }
};
}
'''
SENSORS = r'''
#pragma once
class SensorManager {
public:
bool valid = false;
double latitude = 0, longitude = 0;
mutable unsigned reads = 0;
bool getCachedGpsPosition(double& lat, double& lon) const {
++reads; lat = latitude; lon = longitude; return valid;
}
};
'''
HARNESS = r'''
#include <cassert>
#include <cmath>
#include <limits>
#include <string>
#include <Mesh.h>
#include <helpers/AdvertDataHelpers.h>
#include <helpers/SensorManager.h>
#include <helpers/ui/DisplayNightLocation.h>
using mesh::ui::DisplayNightLocationCache;
static unsigned updates = 0;
static bool observed_valid = false;
static int32_t observed_lat = 0, observed_lon = 0;
static uint32_t observed_utc = 0;
namespace mesh { namespace ui {
void updateDisplayNightMode(uint32_t utc, bool valid, int32_t lat, int32_t lon) {
++updates; observed_valid = valid; observed_lat = lat; observed_lon = lon;
observed_utc = utc;
}
} }
static void expect(DisplayNightLocationCache& cache, uint32_t now,
int32_t expected_lat, int32_t expected_lon) {
int32_t lat = 123, lon = 123;
assert(cache.average(now, lat, lon));
assert(lat == expected_lat && lon == expected_lon);
}
static void missing(DisplayNightLocationCache& cache, uint32_t now) {
int32_t lat = 123, lon = 123;
assert(!cache.average(now, lat, lon));
assert(lat == 0 && lon == 0);
}
static void advert(mesh::Packet& packet, uint32_t prefix, uint32_t now,
int32_t latitude = 10000000, int32_t longitude = 20000000,
uint8_t type = ADV_TYPE_CHAT) {
uint8_t bytes[9];
bytes[0] = type | ADV_LATLON_MASK;
memcpy(bytes + 1, &latitude, 4); memcpy(bytes + 5, &longitude, 4);
mesh::ui::observeDisplayNightAdvert(&packet, mesh::Identity(prefix),
mesh::Identity(99), bytes, sizeof(bytes), now);
}
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
DisplayNightLocationCache cache;
static_assert(sizeof(cache) == 192, "The 16 observation cache must stay 192 bytes");
const uint32_t max_age = DisplayNightLocationCache::MAX_AGE_MS;
if (scenario == "unique") {
missing(cache, 0);
assert(cache.observe(1, 10000000, 20000000, 0));
assert(cache.observe(2, 30000000, 40000000, 1));
for (unsigned i = 0; i < 1000; ++i)
assert(cache.observe(1, 10000000, 20000000, i + 2));
expect(cache, 1002, 20000000, 30000000);
cache.observe(1, 50000000, 60000000, 1003);
expect(cache, 1003, 40000000, 50000000);
} else if (scenario == "freshness") {
cache.observe(1, 10000000, 20000000, 100);
expect(cache, 100 + max_age, 10000000, 20000000);
missing(cache, 101 + max_age);
missing(cache, 100); // Expiration cannot resurrect after millis wraps.
cache.observe(1, 10000000, 20000000, UINT32_MAX - 100);
expect(cache, 100, 10000000, 20000000);
missing(cache, max_age + 1);
} else if (scenario == "capacity") {
for (unsigned i = 1; i <= 16; ++i)
cache.observe(i, i * 1000000, 20000000, i);
expect(cache, 17, 8500000, 20000000);
cache.observe(17, 17000000, 20000000, 17);
expect(cache, 17, 9500000, 20000000); // Replaced oldest, not a recent peer.
cache.observe(2, 2000000, 20000000, 18);
cache.observe(18, 18000000, 20000000, 19);
expect(cache, 19, 10437500, 20000000); // Identity 2 was refreshed, 3 evicted.
} else if (scenario == "coordinates") {
for (const auto& point : {std::pair<int32_t,int32_t>{0,0},
{90000001,0}, {-90000001,0}, {0,180000001}, {0,-180000001}})
assert(!cache.observe(1, point.first, point.second, 0));
missing(cache, 0);
cache.observe(1, -12345678, 23456789, 0);
expect(cache, 0, -12350000, 23460000);
cache.observe(1, 90000000, 180000000, 1);
expect(cache, 1, 90000000, 180000000);
} else if (scenario == "dateline") {
cache.observe(1, 10000000, 179000000, 0);
cache.observe(2, 30000000, -179000000, 0);
expect(cache, 0, 20000000, 180000000);
DisplayNightLocationCache reversed;
reversed.observe(2, 30000000, -179000000, 0);
reversed.observe(1, 10000000, 179000000, 0);
int32_t lat, lon;
assert(reversed.average(0, lat, lon) && lat == 20000000 && std::abs(lon) == 180000000);
cache.observe(3, 20000000, 180000000, 0);
expect(cache, 0, 20000000, 180000000);
} else if (scenario == "gps") {
SensorManager sensors;
cache.observe(1, 10000000, 20000000, 0);
int32_t lat, lon;
assert(mesh::ui::readDisplayNightLocation(cache, sensors, 0, lat, lon));
assert(lat == 10000000 && lon == 20000000);
sensors.valid = true; sensors.latitude = 47.6062; sensors.longitude = -122.3321;
assert(mesh::ui::readDisplayNightLocation(cache, sensors, 1, lat, lon));
assert(lat == 47606200 && lon == -122332100);
assert(mesh::ui::readDisplayNightLocation(cache, sensors, max_age + 1, lat, lon));
sensors.valid = false;
assert(!mesh::ui::readDisplayNightLocation(cache, sensors, max_age + 2, lat, lon));
cache.observe(1, 10000000, 20000000, max_age + 2);
sensors.valid = true;
for (double invalid : {std::numeric_limits<double>::quiet_NaN(),
std::numeric_limits<double>::infinity(), 91.0}) {
sensors.latitude = invalid;
assert(mesh::ui::readDisplayNightLocation(cache, sensors, max_age + 2, lat, lon));
assert(lat == 10000000 && lon == 20000000);
}
} else if (scenario == "filter") {
mesh::Packet packet;
SensorManager sensors;
packet.hops = 1; advert(packet, 1, 0);
packet.hops = 0; packet.transport = true; advert(packet, 2, 0); // Share.
packet.transport = false; packet.forwarded = true; advert(packet, 3, 0); // Bridge.
packet.forwarded = false; packet.radio_local = true; advert(packet, 4, 0); // Import.
packet.radio_local = false; advert(packet, 99, 0); // Self.
advert(packet, 5, 0, 0, 0);
const uint8_t missing_coords[] = {ADV_TYPE_CHAT};
mesh::ui::observeDisplayNightAdvert(&packet, mesh::Identity(6), mesh::Identity(99),
missing_coords, sizeof(missing_coords), 0);
const uint8_t truncated[] = {ADV_TYPE_CHAT | ADV_LATLON_MASK};
mesh::ui::observeDisplayNightAdvert(&packet, mesh::Identity(7), mesh::Identity(99),
truncated, sizeof(truncated), 0);
mesh::ui::refreshDisplayNightLocation(sensors, 123, 0);
assert(updates == 1 && !observed_valid && observed_utc == 123);
// All advertised role types qualify, independent of contact admission.
for (uint8_t type : {ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR})
advert(packet, type, 1, type * 10000000, 20000000, type);
for (unsigned i = 0; i < 100; ++i) advert(packet, 1, 2);
mesh::ui::refreshDisplayNightLocation(sensors, 124, 14999);
assert(updates == 1); // Source refresh is bounded to one pass per 15 seconds.
mesh::ui::refreshDisplayNightLocation(sensors, 125, 15000);
assert(updates == 2 && observed_valid && observed_lat == 25000000 && observed_lon == 20000000);
packet.hops = 1; advert(packet, 1, max_age);
mesh::ui::refreshDisplayNightLocation(sensors, 126, max_age + 3);
assert(updates == 3 && !observed_valid); // Relayed traffic did not extend RF age.
} else assert(false);
}
'''
class DisplayNightLocationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get("CXX", "g++")
if shutil.which(compiler) is None:
raise unittest.SkipTest("C++ compiler unavailable")
cls.temp = tempfile.TemporaryDirectory(prefix="mesh-night-location-")
temp = Path(cls.temp.name)
(temp / "helpers").mkdir()
(temp / "Mesh.h").write_text(MESH, encoding="ascii")
(temp / "Arduino.h").write_text("#pragma once\n", encoding="ascii")
(temp / "helpers/SensorManager.h").write_text(SENSORS, encoding="ascii")
(temp / "main.cpp").write_text(HARNESS, encoding="ascii")
cls.exe = temp / "location"
subprocess.run([
compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", "-Wno-reorder",
"-DDISPLAY_CLASS=ST7735Display",
"-I", str(temp), "-I", str(ROOT / "src"), str(temp / "main.cpp"),
str(ROOT / "src/helpers/ui/DisplayNightLocation.cpp"),
str(ROOT / "src/helpers/AdvertDataHelpers.cpp"), "-o", str(cls.exe),
], check=True, capture_output=True, text=True)
@classmethod
def tearDownClass(cls):
cls.temp.cleanup()
def test_location_sources(self):
for scenario in ("unique", "freshness", "capacity", "coordinates", "dateline", "gps", "filter"):
with self.subTest(scenario=scenario):
subprocess.run([str(self.exe), scenario], check=True)
if __name__ == "__main__":
unittest.main()
+238
View File
@@ -0,0 +1,238 @@
"""Run the real night schedule, saved CLI and redraw policy on the host."""
from datetime import datetime, timezone, timedelta
from pathlib import Path
import math
import os
import subprocess
import tempfile
import unittest
from test_display_power import FS_STUB
ROOT = Path(__file__).resolve().parents[1]
def compile_run(source, files=(), defines=(), stdin=""):
with tempfile.TemporaryDirectory(prefix="mesh-night-") as directory:
work = Path(directory)
(work / "helpers").mkdir()
(work / "helpers/IdentityStore.h").write_text(
FS_STUB.replace("/display_prefs", "/display_night"), encoding="ascii")
(work / "Arduino.h").write_text(
'#pragma once\n#include <stdint.h>\ninline uint32_t millis() { return 0; }\n', encoding="ascii")
(work / "test.cpp").write_text(source, encoding="ascii")
command = [os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-I" + str(work), "-I" + str(ROOT / "src"), *defines,
str(work / "test.cpp"), *(str(ROOT / path) for path in files), "-o", str(work / "test")]
built = subprocess.run(command, capture_output=True, text=True)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
ran = subprocess.run([str(work / "test")], input=stdin, capture_output=True, text=True)
if ran.returncode:
raise AssertionError(ran.stdout + ran.stderr)
return ran.stdout
class DisplayNightModeTests(unittest.TestCase):
def test_small_integer_schedule_against_independent_solar_reference(self):
# NOAA's general solar equations provide an independent, more precise
# test oracle, not a firmware dependency:
# https://gml.noaa.gov/grad/solcalc/solareqns.PDF
samples = []
for latitude, longitude in ((47.6, -122.3), (0, 0), (-33.9, 151.2),
(51.5, 0), (-45, 170), (60, 25),
(35, 179.8), (35, -179.8)):
for month in (1, 3, 6, 9, 12):
date = datetime(2028, month, 21, tzinfo=timezone.utc)
gamma = 2 * math.pi / 365 * (date.timetuple().tm_yday - 1)
eqtime = 229.18 * (0.000075 + 0.001868 * math.cos(gamma)
- 0.032077 * math.sin(gamma) - 0.014615 * math.cos(2 * gamma)
- 0.040849 * math.sin(2 * gamma))
decl = (0.006918 - 0.399912 * math.cos(gamma) + 0.070257 * math.sin(gamma)
- 0.006758 * math.cos(2 * gamma) + 0.000907 * math.sin(2 * gamma)
- 0.002697 * math.cos(3 * gamma) + 0.001480 * math.sin(3 * gamma))
lat = math.radians(latitude)
hour = math.degrees(math.acos((math.cos(math.radians(90.833))
- math.sin(lat) * math.sin(decl)) / (math.cos(lat) * math.cos(decl))))
dawn = 720 - 4 * longitude - eqtime - 4 * hour
dusk = 720 - 4 * longitude - eqtime + 4 * hour
for delay in (-120, 0, 60, 180):
# One hour either side of the reference transition: broad
# deliberately, matching this approximate dusk feature.
for minute, expected in ((dawn - 60, True), (dawn + 60, False),
(dusk + delay - 60, False), (dusk + delay + 60, True)):
# Near midsummer at 60N a three-hour dusk delay can
# consume all of the short night. Test ordinary-night
# samples here and exercise that boundary separately.
if 1440 - 8 * hour - delay <= 120 or 8 * hour + delay <= 120:
continue
utc = int((date + timedelta(minutes=minute)).timestamp())
samples.append((utc, round(latitude * 1e6), round(longitude * 1e6), delay, expected))
source = r'''
#include <helpers/ui/DisplayNightPolicy.h>
#include <cstdio>
int main() {
unsigned utc; int lat, lon, delay;
while (scanf("%u %d %d %d", &utc, &lat, &lon, &delay) == 4)
printf("%d\n", mesh::ui::night_policy::active(utc, lat, lon, delay));
}
'''
output = compile_run(source, stdin="".join(
f"{utc} {lat} {lon} {delay}\n" for utc, lat, lon, delay, _ in samples))
for sample, actual in zip(samples, output.splitlines()):
self.assertEqual(actual == "1", sample[-1], sample)
self.assertEqual(len(output.splitlines()), len(samples))
def test_polar_invalid_and_midnight_delay_boundaries(self):
compile_run(r'''
#include <helpers/ui/DisplayNightPolicy.h>
#include <cassert>
using namespace mesh::ui::night_policy;
int main() {
const uint32_t june = 1782000000; // 2026-06-21 midnight UTC
const uint32_t december = 1797811200; // 2026-12-21 midnight UTC
assert(!active(june, 85000000, 0, 60));
assert(active(december, 85000000, 0, 60));
assert(active(june, -85000000, 0, 60));
assert(!active(december, -85000000, 0, 60));
assert(!active(0, 47000000, -122000000, 60));
assert(!active(june, 91000000, 0, 60));
assert(!active(june, 0, -181000000, 60));
// Longitude arithmetic must remain bounded at the uint32 RTC ceiling.
assert(active(UINT32_MAX, 0, 180000000, 60)
== active(UINT32_MAX - 86400, 0, 180000000, 60));
// At 60N a long positive delay crosses midnight. It must not activate
// before that previous sunset's delay, nor remain red after sunrise.
assert(!active(june, 60000000, 0, 180));
assert(active(june + 3600, 60000000, 0, 180));
assert(!active(june + 6 * 3600, 60000000, 0, 180));
}
''')
def test_real_cli_persistence_failed_saves_and_immediate_manual_mode(self):
source = r'''
#include <helpers/ui/DisplayNightSettings.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <cassert>
#include <string>
#if defined(NRF52_PLATFORM)
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#endif
using namespace mesh::ui;
int main() {
FakeFS fs;
char reply[160];
loadDisplayPowerSettings(&fs, true);
assert(displayNightMode() == NightAuto && displayNightDelayMinutes() == 60);
assert(!displayNightActive());
assert(handleDisplayPowerCommand("set display.night on", reply, sizeof(reply)));
assert(!strcmp(reply, "OK") && displayNightActive()); // no clock/GPS needed
assert(fs.files["/display_night"].size() == 8);
assert(handleDisplayPowerCommand("get display.night", reply, sizeof(reply)));
assert(!strcmp(reply, "> on"));
assert(handleDisplayPowerCommand("set display.night.delay -180", reply, sizeof(reply)));
assert(!strcmp(reply, "OK") && displayNightDelayMinutes() == -180);
assert(handleDisplayPowerCommand("set display.night.delay 0", reply, sizeof(reply)));
assert(!strcmp(reply, "OK") && displayNightDelayMinutes() == 0);
assert(handleDisplayPowerCommand("set display.night off", reply, sizeof(reply)));
assert(!strcmp(reply, "OK") && !displayNightActive());
loadDisplayNightSettings(&fs);
assert(displayNightMode() == NightOff && displayNightDelayMinutes() == 0);
for (const char* cmd : {"set display.night AUTO", "set display.night.delay 181",
"set display.night.delay -181", "set display.night.delay 1junk",
"set display.night.delay ", "get display.night 1"}) {
assert(handleDisplayPowerCommand(cmd, reply, sizeof(reply)));
assert(std::string(reply).find("Error:") == 0);
assert(displayNightMode() == NightOff && displayNightDelayMinutes() == 0);
}
fs.fail_write = true;
assert(handleDisplayPowerCommand("set display.night on", reply, sizeof(reply)));
assert(std::string(reply).find("Error:") == 0 && !displayNightActive());
fs.fail_write = false;
assert(handleDisplayPowerCommand("set display.night auto", reply, sizeof(reply)));
updateDisplayNightMode(1797811200, true, 85000000, 0);
assert(displayNightActive());
updateDisplayNightMode(1797811201, false);
assert(!displayNightActive()); // lost/expired location is not permanent GPS
updateDisplayNightMode(1797811202, true, 85000000, 0);
assert(displayNightActive());
fs.files["/display_night"][7] ^= 1;
loadDisplayNightSettings(&fs);
assert(displayNightMode() == NightAuto && displayNightDelayMinutes() == 60);
// Recovery followed by a failed update must preserve the recovered record.
assert(handleDisplayPowerCommand("set display.night on", reply, sizeof(reply)));
fs.files["/display_night.bak"] = fs.files["/display_night"];
fs.files["/display_night"][0] ^= 1;
loadDisplayNightSettings(&fs);
assert(displayNightMode() == NightOn);
fs.fail_publish = true;
handleDisplayPowerCommand("set display.night off", reply, sizeof(reply));
assert(std::string(reply).find("Error:") == 0);
fs.fail_publish = false;
loadDisplayNightSettings(&fs);
assert(displayNightMode() == NightOn && displayNightActive());
}
'''
files = ("src/helpers/ui/DisplayNightMode.cpp", "src/helpers/ui/DisplayPowerSettings.cpp")
for platform in ((), ("-DNRF52_PLATFORM",)):
with self.subTest(platform=platform):
compile_run(source, files, ("-DDISPLAY_CLASS=ST7735Display", *platform))
def test_monochrome_has_no_night_runtime_or_store(self):
compile_run(r'''
#include <helpers/ui/DisplayNightSettings.h>
#include <cassert>
int main() {
FakeFS fs;
mesh::ui::loadDisplayNightSettings(&fs);
mesh::ui::updateDisplayNightMode(1797811200, true, 85000000, 0);
assert(!mesh::ui::displayNightActive() && fs.calls == 0);
}
''', ("src/helpers/ui/DisplayNightMode.cpp",), ("-DDISPLAY_CLASS=SSD1306Display",))
def test_palette_transition_invalidates_cached_ui_without_power_cycling(self):
compile_run(r'''
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/DisplayNightSettings.h>
#include <cassert>
struct Screen : DisplayDriver {
Screen() : DisplayDriver(160, 80) {}
bool on = true;
unsigned starts = 0, stops = 0;
bool isOn() override { return on; }
void turnOn() override { on = true; ++starts; }
void turnOff() override { on = false; ++stops; }
void clear() override {}
void startFrame(ColorVal) override {}
void endFrame() override {}
void setTextSize(int) override {}
void setColor(ColorVal) override {}
void setCursor(int, int) override {}
void print(const char*) override {}
void fillRect(int, int, int, int) override {}
void drawRect(int, int, int, int) override {}
void drawXbm(int, int, const uint8_t*, int, int) override {}
uint16_t getTextWidth(const char*) override { return 6; }
};
int main() {
FakeFS fs;
mesh::ui::loadDisplayNightSettings(&fs);
mesh::ui::displayPowerPrefs().battery.mode = mesh::ui::DisplayMode::On;
Screen display;
assert(!display.servicePower(false));
char reply[160];
mesh::ui::handleDisplayNightCommand("set display.night on", reply, sizeof(reply));
assert(display.servicePower(false)); // existing UI power hook forces redraw
assert(!display.servicePower(false));
mesh::ui::handleDisplayNightCommand("set display.night off", reply, sizeof(reply));
assert(display.servicePower(false));
assert(!display.servicePower(false));
assert(display.starts == 0 && display.stops == 0);
}
''', ("src/helpers/ui/DisplayNightMode.cpp", "src/helpers/ui/DisplayDriver.cpp"),
("-DDISPLAY_CLASS=ST7735Display",))
if __name__ == "__main__":
unittest.main()
+291
View File
@@ -0,0 +1,291 @@
"""Execute actual color boundaries, indexed palettes and night emoji rendering."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / 'src/helpers/ui'
PREFIX = r'''
#include <helpers/ui/ColorTheme.h>
#include <cassert>
#include <cstdint>
#include <vector>
#include <type_traits>
static bool night = false;
namespace mesh { namespace ui { bool displayNightActive() { return night; } } }
ColorVal UIColor::window_bkg=0x0885, UIColor::title_bkg=0x1299, UIColor::title_txt=0xffff;
ColorVal UIColor::primary_txt=0xe73c, UIColor::secondary_txt=0x8d59, UIColor::warning_txt=0xfd20;
ColorVal UIColor::popup_bkg=0x1299, UIColor::popup_txt=0xffff, UIColor::corp_blue=0x5dbf;
struct Canvas {
uint32_t fill=0, text=0;
std::vector<uint32_t> marks;
void fillScreen(uint32_t c) { fill=c; }
void setTextColor(uint32_t c) { text=c; }
void setBaseColor(uint32_t) {}
void setTextSize(int) {}
void setTextWrap(bool) {}
void setFreeFont() {}
void cp437(bool) {}
void fillRect(int,int,int,int,uint32_t c) { marks.push_back(c); }
};
'''
class DisplayNightRenderingTests(unittest.TestCase):
def run_cpp(self, source, *defines):
with tempfile.TemporaryDirectory(prefix='meshcore-night-render-') as directory:
executable = Path(directory) / 'render'
compiled = subprocess.run([
os.environ.get('CXX', 'c++'), '-std=c++11', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', '-Wno-unused-function', '-Wno-unused-variable',
'-DDISPLAY_CLASS=LGFXDisplay', *('-D' + value for value in defines),
'-I', str(ROOT / 'src'), '-x', 'c++', '-', '-o', str(executable),
], input=source, text=True, capture_output=True)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
ran = subprocess.run([str(executable)], text=True, capture_output=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_all_rgb565_values_and_variant_semantic_backgrounds(self):
self.run_cpp(PREFIX + r'''
int main() {
using namespace mesh::ui::color_theme;
for (unsigned c=0;c<65536;++c) assert(render(c)==c && background(c)==c);
night=true;
for (unsigned c=0;c<65536;++c) {
const bool b=c==0 || c==0x0885 || c==0x1299 || c==WINDOW_BACKGROUND
|| c==TITLE_BACKGROUND || c==POPUP_BACKGROUND;
assert(render(c)==(b ? NIGHT_BACKGROUND : NIGHT_FOREGROUND));
assert(background(c)==NIGHT_BACKGROUND);
}
assert(render(TOUCH_OUTLINE)==0xf800 && render(TOUCH_PRESSED)==0xf800);
assert(UIColor::window_bkg==0x0885 && UIColor::primary_txt==0xe73c);
night=false;
assert(render(UIColor::window_bkg)==0x0885 && render(UIColor::primary_txt)==0xe73c);
}
''')
def test_actual_direct_and_buffered_color_boundaries(self):
for driver in ('ST7735Display', 'ST7789LCDDisplay', 'NV3001BDisplay'):
with self.subTest(driver=driver):
source = (UI / (driver + '.cpp')).read_text()
if driver == 'ST7735Display':
harness = r'''
static Canvas canvas; static Canvas* sprite=&canvas; static ColorVal curr_color;
bool spriteReady() { return true; }
struct ST7735Display {
void startFrame(ColorVal); void setColor(ColorVal); void fillRect(int,int,int,int);
};
'''
inspect = 'canvas'
elif driver == 'ST7789LCDDisplay':
harness = r'''
struct ST7789LCDDisplay {
Canvas display, canvas; Canvas* _canvas=&canvas;
bool buffered=false, _isOn=true, _frame_active=false; ColorVal _color=0;
bool isBufferedRendering() const { return buffered; }
int width() const { return 8; } int height() const { return 8; }
void serviceRender(bool) {} void finishCanvasPrimitive(int,int,int,int) {}
void startFrame(ColorVal); void setColor(ColorVal); void fillRect(int,int,int,int);
};
'''
inspect = '(d.buffered ? d.canvas : d.display)'
else:
harness = r'''
static const int NV3001B_SCREEN_WIDTH=8, NV3001B_SCREEN_HEIGHT=8;
struct NV3001BDisplay {
Canvas canvas; ColorVal color=0; int text_size=1,cursor_x=0,cursor_y=0;
void fillPhysicalRect(int x,int y,int w,int h) {
canvas.fillRect(x,y,w,h,color); if (w==8 && h==8) canvas.fill=color;
}
void startFrame(ColorVal); void setColor(ColorVal); void fillRect(int,int,int,int);
};
'''
inspect = 'd.canvas'
harness += '\n'.join(body(source, f'void {driver}::{name}(')
for name in ('startFrame', 'setColor', 'fillRect'))
harness += '\nint main() { ' + driver + ' d;\n'
if driver == 'ST7789LCDDisplay':
harness += 'for (bool buffered : {false,true}) { d.buffered=buffered;\n'
harness += 'auto& c=' + inspect + ';\n'
harness += r'''
night=false; d.startFrame(0x3456); assert(c.fill==0x3456);
d.setColor(0x07e0); d.fillRect(1,1,1,1); assert(c.marks.back()==0x07e0);
night=true; d.startFrame(0x3456); assert(c.fill==0);
d.setColor(UIColor::primary_txt); d.fillRect(1,1,1,1); assert(c.marks.back()==0xf800);
d.setColor(0xffe0); d.fillRect(1,1,1,1); assert(c.marks.back()==0xf800);
d.setColor(UIColor::popup_bkg); d.fillRect(1,1,1,1); assert(c.marks.back()==0);
night=false; d.startFrame(0x3456); assert(c.fill==0x3456);
'''
if driver == 'ST7789LCDDisplay':
harness += '}\n'
harness += '}\n'
self.run_cpp(PREFIX + harness, 'LILYGO_TDECK=1')
def test_actual_lgfx_indexed_and_truecolor_emoji_paths(self):
source = (UI / 'LGFXDisplay.cpp').read_text()
palette = body(source, 'static const uint32_t UI_PALETTE[16]') + ';\n'
harness = r'''
namespace lgfx { namespace v1 { struct LGFXBase {}; } }
struct Sprite : Canvas, lgfx::v1::LGFXBase {
uint32_t palette[16]={};
void setPaletteColor(unsigned i,uint32_t c) { palette[i]=c; }
template<class T> void fillRect(int,int,int,int,T c) {
// LovyanGFX treats signed32 as RGB565, unsigned32 as RGB888.
#if UI_BUFFER_COLOR_DEPTH < 8
marks.push_back(c);
#else
marks.push_back(std::is_signed<T>::value
? mesh::ui::color_theme::rgb888(static_cast<ColorVal>(c)) : c);
#endif
}
};
struct Atlas {
const uint8_t* glyph(uint16_t) const { static uint8_t p[]={255,0xe0,0x1c,3}; return p; }
int width() const { return 2; } int height() const { return 2; }
uint8_t transparent() const { return 255; }
};
struct LGFXDisplay {
Canvas panel; Canvas* display=&panel; Sprite buffer; Atlas _emojiAtlas;
struct EmojiOverlay { uint16_t codepoint; int16_t x,y; uint16_t size; } _emojiOverlays[2];
static const unsigned MAX_EMOJI_OVERLAYS=2;
unsigned _emojiOverlayCount=0,_presentedEmojiOverlayCount=0;
bool _hasTransparentEmojiPixels=false,_nightFrame=false,_hasLastFrame=true;
uint32_t _color=0;
bool wasEmojiPresented(const EmojiOverlay&) { return false; }
void drawEmojiMask(int,int,int,const uint8_t*) { assert(false); }
void drawEmojiUnderlay(int,int,int,const uint8_t*) {}
void startFrame(ColorVal); void setColor(ColorVal); void applyNightPalette();
uint32_t renderColor(ColorVal) const;
int32_t queueEmoji(lgfx::v1::LGFXBase*,int32_t,int32_t,uint32_t,int32_t);
};
'''
harness += '\n'.join(body(source, signature) for signature in (
'void LGFXDisplay::startFrame(', 'void LGFXDisplay::setColor(',
'void LGFXDisplay::applyNightPalette(', 'uint32_t LGFXDisplay::renderColor(',
'int32_t LGFXDisplay::queueEmoji('))
harness += r'''
int main() {
LGFXDisplay d; night=true; d.startFrame(0xffff);
assert(!d._hasLastFrame && d.panel.fill==0 && d.buffer.fill==0);
#if UI_BUFFER_COLOR_DEPTH < 8
assert(d.buffer.palette[0]==0 && d.buffer.palette[1]==0xff0000);
for (unsigned i=1;i<16;++i) assert(d.buffer.palette[i]==0xff0000);
const uint32_t red=1;
const uint32_t physical_red=1;
#else
const uint32_t red=0xf800;
const uint32_t physical_red=0xff0000;
#endif
assert(d.buffer.text==red && d.renderColor(0x07e0)==red);
assert(d.queueEmoji(&d.buffer,0,0,0x1234,4)==4);
assert(d._emojiOverlayCount==0 && !d._hasTransparentEmojiPixels);
assert(d.buffer.marks.size()==3);
for (uint32_t color : d.buffer.marks) assert(color==physical_red);
night=false; d.startFrame(0x3456);
assert(!d._hasLastFrame && d.panel.fill==UIColor::window_bkg);
#if UI_BUFFER_COLOR_DEPTH < 8
assert(d.buffer.palette[0]==UI_PALETTE[0] && d.buffer.palette[1]==UI_PALETTE[1]);
for (unsigned i=0;i<16;++i) assert(d.buffer.palette[i]==UI_PALETTE[i]);
#else
assert(d.buffer.fill==0x3456);
#endif
assert(d.queueEmoji(&d.buffer,0,0,0x1234,4)==4 && d._emojiOverlayCount==1);
}
'''
for depth in (4, 8, 16):
with self.subTest(depth=depth):
self.run_cpp(PREFIX + palette + harness, f'UI_BUFFER_COLOR_DEPTH={depth}')
def test_color_capability_excludes_monochrome_eink_and_null(self):
colors = ('ST7735Display', 'ST7789Display', 'ST7789LCDDisplay', 'NV3001BDisplay',
'LGFXDisplay', 'SCIndicatorDisplay', 'WioTrackerL2Display')
others = ('SSD1306Display', 'SH1106Display', 'SH1107Display', 'U8g2Display',
'GxEPDDisplay', 'E213Display', 'E290Display', 'NullDisplayDriver')
for driver in colors + others:
with self.subTest(driver=driver):
code = '#define DISPLAY_CLASS ' + driver + '\n#include <helpers/ui/DisplayBuildFlags.h>\n'
code += '#ifdef MESHCORE_HAS_COLOR_DISPLAY\nconstexpr bool color=true;\n#else\nconstexpr bool color=false;\n#endif\n'
code += 'static_assert(color == ' + ('true' if driver in colors else 'false') + ', "capability");\n'
compiled = subprocess.run(['c++', '-std=c++11', '-fsyntax-only', '-I', str(ROOT / 'src'),
'-x', 'c++', '-'], input=code, text=True, capture_output=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
def test_actual_qr_paths_keep_red_quiet_zone_and_black_modules_at_night(self):
qr_common = r'''
struct QRCode { uint8_t size=21; };
const int ECC_LOW=0;
int qrcode_initText(QRCode* qr,uint8_t*,uint8_t,int,const char*) { qr->size=21;return 0; }
bool qrcode_getModule(const QRCode*,unsigned col,unsigned row) { return col==row || col==0; }
int lgfx_qrcode_initText(QRCode* q,uint8_t* b,uint8_t v,int e,const char* t) { return qrcode_initText(q,b,v,e,t); }
bool lgfx_qrcode_getModule(const QRCode* q,unsigned c,unsigned r) { return qrcode_getModule(q,c,r); }
'''
source = (UI / 'DisplayDriver.cpp').read_text()
generic = r'''
struct QrDisplay {
Canvas canvas; uint32_t color=0;
int width() const { return 128; } int height() const { return 128; }
bool isEink() const { return false; }
void setColor(ColorVal c) { color=mesh::ui::color_theme::render(c); }
void fillRect(int x,int y,int w,int h) { canvas.fillRect(x,y,w,h,color); }
bool drawQrCodeWithBorder(const char*,int,int,int,int);
};
'''
generic += body(source, 'bool DisplayDriver::drawQrCodeWithBorder(').replace('DisplayDriver::', 'QrDisplay::')
generic += r'''
int main() {
for (bool active : {false,true}) {
night=active; QrDisplay d;
assert(d.drawQrCodeWithBorder("X",0,0,64,-1));
assert(d.canvas.marks.size()==42);
assert(d.canvas.marks[0]==(night ? 0xf800 : UIColor::popup_txt));
for (unsigned i=1;i<d.canvas.marks.size();++i)
assert(d.canvas.marks[i]==(night ? 0 : UIColor::window_bkg));
}
}
'''
self.run_cpp(PREFIX + qr_common + generic)
source = (UI / 'LGFXDisplay.cpp').read_text()
lgfx = r'''
struct LGFXDisplay {
Canvas buffer; int _coordinateScale=2;
int width() const { return 128; } int height() const { return 128; }
uint32_t renderColor(ColorVal c) const;
bool drawQrCode(const char*,int,int,int);
};
'''
lgfx += body(source, 'uint32_t LGFXDisplay::renderColor(')
lgfx += body(source, 'bool LGFXDisplay::drawQrCode(')
lgfx += r'''
int main() {
for (bool active : {false,true}) {
night=active; LGFXDisplay d;
assert(d.drawQrCode("X",0,0,64));
assert(d.buffer.marks.size()==42);
#if UI_BUFFER_COLOR_DEPTH < 8
const uint32_t light = 1u;
#else
const uint32_t light = night ? 0xff0000u : UIColor::primary_txt;
#endif
#if UI_BUFFER_COLOR_DEPTH < 8
const uint32_t dark = 0u;
#else
const uint32_t dark = night ? 0u : UIColor::window_bkg;
#endif
assert(d.buffer.marks[0]==light);
for (unsigned i=1;i<d.buffer.marks.size();++i)
assert(d.buffer.marks[i]==dark);
}
}
'''
for depth in (4, 8, 16):
with self.subTest(qr_depth=depth):
self.run_cpp(PREFIX + qr_common + lgfx, f'UI_BUFFER_COLOR_DEPTH={depth}')
if __name__ == '__main__':
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Exercise color-display night controls through real config handlers and UI."""
import json
from pathlib import Path
import unittest
import test_webconfig_lost_reply as web_fixture
ROOT = Path(__file__).resolve().parents[1]
class DisplayNightWebConfigTest(unittest.TestCase):
compile_run = web_fixture.LostReplyWebConfigTest.compile_run
setup_values = web_fixture.LostReplyWebConfigTest.setup_values
run_page = web_fixture.LostReplyWebConfigTest.run_page
def test_actual_snapshot_and_authenticated_cli_batch(self):
fixture = web_fixture.LostReplyWebConfigTest.fixture(self)
fixture = fixture[:fixture.index('String writeBody(')]
for color in (False, True):
with self.subTest(color=color):
source = r'''
#include <cstdint>
#define MESHCORE_HAS_REAL_DISPLAY 1
@COLOR@
namespace mesh { namespace ui {
struct Profile { uint8_t mode=0; uint16_t seconds=15; };
struct Prefs { Profile battery, usb; };
const Prefs& displayPowerPrefs() { static Prefs prefs; return prefs; }
const char* displayModeName(uint8_t) { return "button"; }
bool displayPairingSupported() { return false; }
uint8_t night_mode=2;
int16_t night_delay=60;
bool night_active=false;
uint8_t displayNightMode() { return night_mode; }
int16_t displayNightDelayMinutes() { return night_delay; }
bool displayNightActive() { return night_active; }
}}
'''.replace('@COLOR@', '#define MESHCORE_HAS_COLOR_DISPLAY 1' if color else '') + fixture + r'''
int main() {
assert(mesh::esp32::managementPassword::publish("password"));
for(unsigned mode=0;mode<3;++mode) {
WebConfigServer server;
mesh::ui::night_mode=mode;mesh::ui::night_delay=mode?0:-30;mesh::ui::night_active=mode==1;
AsyncWebServerRequest get;server.handleConfigGet(&get);
JsonDocument snapshot;
assert(get.status==200 && deserializeJson(snapshot,get.output)==DeserializationError::Ok);
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const char* names[]={"off","on","auto"};
assert(snapshot["display"]["night_supported"].as<bool>());
assert(strcmp(snapshot["display"]["night"].as<const char*>(),names[mode])==0);
assert(snapshot["display"]["night_delay"].as<int>()==(mode?0:-30));
assert(snapshot["display"]["night_active"].as<bool>()==(mode==1));
#else
assert(snapshot["display"]["night_supported"].isNull());
assert(snapshot["display"]["night"].isNull());
#endif
}
for(const char* key:{"display.night","display.night.delay"}) {
assert(wcIsAllowedSetKey(key) && !wcSetKeyRequiresReboot(key));
WebConfigServer server;
const char* value=strcmp(key,"display.night")==0?"auto":"-30";
String body=String("{\"reqid\":\"0123456789abcdef\",\"set\":{\"")+key+"\":\""+value+"\"}}";
AsyncWebServerRequest unauthenticated(body);unauthenticated.header.cookies.clear();
server.handleConfigPost(&unauthenticated);
assert(unauthenticated.status==401 && !server._batch_count);
AsyncWebServerRequest save(body);server.handleConfigPost(&save);
assert(save.status==202 && server._batch_count==1 && !server._batch_reboot);
assert(String(server._batch[0].cmd)==String("set ")+key+" "+value);
}
for(const char* key:{"display.night.extra","display.night.delay.extra"})assert(!wcIsAllowedSetKey(key));
}
'''
self.compile_run(source)
def test_color_form_preserves_zero_and_saves_only_changed_night_fields(self):
status, config = self.setup_values()
status.update(mode="lan")
config["display"] = {"mode": "button", "timeout": 15, "usb_mode": "on", "usb_timeout": 15,
"night_supported": True, "night": "auto", "night_delay": 0, "night_active": False}
prelude = r'''<script>
(function(){
var status=%s,config=%s,submitted=null;
function reply(value,code){return Promise.resolve({ok:true,status:code||200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path,options){
if(path==="/api/status")return reply(status);
if(path==="/api/config"&&(!options||options.method!=="POST"))return reply(config);
if(path==="/api/config"){
submitted=JSON.parse(options.body);config.display.night=submitted.set["display.night"];
config.display.night_delay=Number(submitted.set["display.night.delay"]);
return reply({state:"pending",reqid:submitted.reqid,count:2},202);
}
if(path.indexOf("/api/config/result?")===0)return reply({state:"done",reqid:submitted.reqid,results:[{key:"display.night",reply:"OK"},{key:"display.night.delay",reply:"OK"}]});
return reply({});
};
function mark(key,value){document.body.setAttribute("data-test-night-"+key,String(value))}
window.addEventListener("load",function(){
setTimeout(function(){enterApp()},50);
setTimeout(function(){
var group=document.getElementById("display-night-settings"),mode=group.querySelector('select'),delay=group.querySelector('input');
mark("visible",!group.classList.contains("hide"));mark("enabled",!mode.disabled&&!delay.disabled);
mark("mode",mode.value);mark("delay",delay.value);mark("bounds",delay.min+","+delay.max);
mark("choices",Array.from(mode.options).map(function(o){return o.value}).join(","));
mode.value="on";mode.dispatchEvent(new Event("input",{bubbles:true}));
delay.value="-30";delay.dispatchEvent(new Event("input",{bubbles:true}));appSave();
},650);
setTimeout(function(){
mark("keys",submitted?Object.keys(submitted.set).sort().join(","):"missing");
mark("saved",submitted?submitted.set["display.night"]+","+submitted.set["display.night.delay"]:"missing");
mark("no-reboot",submitted&&!submitted.reboot);mark("clean",!st.dirty["display.night"]&&!st.dirty["display.night.delay"]);
},1800);
});
})();
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=2400)
for key, value in (("visible", "true"), ("enabled", "true"), ("mode", "auto"), ("delay", "0"),
("bounds", "-180,180"), ("choices", "auto,on,off"),
("keys", "display.night,display.night.delay"), ("saved", "on,-30"),
("no-reboot", "true"), ("clean", "true")):
self.assertIn('data-test-night-%s="%s"' % (key, value), dom)
def test_monochrome_and_headless_forms_hide_and_disable_night_controls(self):
for display in (None, {"mode": "button", "timeout": 15, "usb_mode": "on", "usb_timeout": 15}):
with self.subTest(display=display):
status, config = self.setup_values()
status.update(mode="lan")
if display is None:
config.pop("display", None)
else:
config["display"] = display
prelude = r'''<script>
window.fetch=function(path){var value=path==="/api/status"?%s:%s;return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})};
window.addEventListener("load",function(){
setTimeout(function(){enterApp()},50);
setTimeout(function(){
var group=document.getElementById("display-night-settings"),body=document.body;
body.setAttribute("data-test-night-hidden",group.classList.contains("hide"));
body.setAttribute("data-test-night-disabled",Array.from(group.querySelectorAll('[data-k]')).every(function(e){return e.disabled}));
body.setAttribute("data-test-night-dirty",Object.keys(st.dirty).length);
},650);
});
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
for key, value in (("hidden", "true"), ("disabled", "true"), ("dirty", "0")):
self.assertIn('data-test-night-%s="%s"' % (key, value), dom)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -27,6 +27,9 @@ HARNESS = r'''
#include <cstdint>
#include <cstring>
#include <vector>
namespace mesh { namespace ui { namespace color_theme {
constexpr uint16_t render(uint16_t color) { return color; }
}}}
constexpr int INPUT = 0, OUTPUT = 1, LOW = 0, HIGH = 1;
constexpr int PIN_TFT_RST = 1, PIN_TFT_CS = 2, PIN_TFT_DC = 3;
+8 -1
View File
@@ -33,7 +33,7 @@ struct Bus {
class Panel {
public:
int displayWidth,displayHeight,_buffheight;
uint16_t _RGB=0xffff;
uint16_t _RGB=0xffff, _lastRGB=0xffff;
int _spiSettings=0;
Bus bus; Bus* _spi=&bus;
std::vector<uint8_t> front,back;
@@ -57,6 +57,13 @@ int main() {
for(int row:p.rows) assert(row==1);
#ifdef OLEDDISPLAY_DOUBLE_BUFFER
p.display(); assert(p.bus.transfers==p.displayHeight);
// A tint-only night transition must flush even if every monochrome
// framebuffer bit is unchanged, then return to normal dirty suppression.
p._RGB=0xf800; p.display();
assert(p.bus.transfers==2*p.displayHeight);
p.display(); assert(p.bus.transfers==2*p.displayHeight);
p._RGB=0xffff; p.display();
assert(p.bus.transfers==3*p.displayHeight);
#endif
}
}
@@ -16,6 +16,8 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) {
EXPECT_TRUE(wcIsAllowedSetKey("powersaving"));
EXPECT_TRUE(wcIsAllowedSetKey("repeat"));
EXPECT_TRUE(wcIsAllowedSetKey("display.rotation"));
EXPECT_TRUE(wcIsAllowedSetKey("display.night"));
EXPECT_TRUE(wcIsAllowedSetKey("display.night.delay"));
EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.rxgain")); // Board::handleCommand(), not CommonCLI
EXPECT_TRUE(wcIsAllowedSetKey("radio.fem.txgain"));
EXPECT_TRUE(wcIsAllowedSetKey("wifi.ssid"));
@@ -41,6 +43,8 @@ TEST(WebConfigKeys, RejectsDangerousOrUnknownKeys) {
EXPECT_FALSE(wcIsAllowedSetKey("reboot"));
EXPECT_FALSE(wcIsAllowedSetKey("bogus"));
EXPECT_FALSE(wcIsAllowedSetKey("display.rotation.extra"));
EXPECT_FALSE(wcIsAllowedSetKey("display.night.extra"));
EXPECT_FALSE(wcIsAllowedSetKey("display.night.delay.extra"));
EXPECT_FALSE(wcIsAllowedSetKey("bluetooth.stealth.extra"));
EXPECT_FALSE(wcIsAllowedSetKey("mqtt1.bogus")); // unknown slot field
EXPECT_FALSE(wcIsAllowedSetKey(""));
+1 -1
View File
@@ -193,7 +193,7 @@ window.addEventListener("load",function(){
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
for attribute, value in (
("display-visible", "true"), ("display-count", "5"),
("display-visible", "true"), ("display-count", "7"),
("battery-timeout", "23"), ("usb-timeout", "55"),
("usb-disabled", "true"), ("pairing-disabled", "true"),
("rotation-hidden", "true"), ("rotation-disabled", "true"),
@@ -17,6 +17,9 @@ ARDUINO = r'''
#include <cstdint>
#include <string>
#include <vector>
namespace mesh { namespace ui { namespace color_theme {
constexpr uint16_t render(uint16_t color) { return color; }
}}}
#define LOW 0
#define HIGH 1
#define INPUT 0
@@ -1,4 +1,5 @@
#pragma once
#include <helpers/ui/ColorTheme.h>
#include <helpers/ui/LGFXDisplay.h>
#include <Wire.h>
@@ -197,9 +198,9 @@ public:
// The zoomed sprite blit stops a pixel short of the panel edge, so those
// pixels keep whatever the panel powered up with (they showed as coloured
// lines down the right side and along the bottom). Clear the panel once.
display->fillScreen(UIColor::window_bkg);
display->fillScreen(mesh::ui::color_theme::background(UIColor::window_bkg));
display->setBrightness(153);
display->setTextColor(TFT_WHITE);
display->setTextColor(mesh::ui::color_theme::render(TFT_WHITE));
buffer.setColorDepth(8);
buffer.setPsram(true);
+16
View File
@@ -412,6 +412,13 @@ body.tab-cli{padding-bottom:0}
<div class="f"><label>Timeout (seconds)</label><input type="number" data-k="display.usb.timeout" min="1" max="3600" step="1"></div>
</div>
<div class="hint">Automatic shows startup and messages while the app is disconnected. Connecting blanks the display; a button or pairing request can wake it. USB power detection depends on the board.</div>
<div class="hide" id="display-night-settings">
<div class="row">
<div class="f"><label>Night mode</label><select data-k="display.night"><option value="auto">Auto</option><option value="on">On</option><option value="off">Off</option></select></div>
<div class="f"><label>Minutes after sunset</label><input type="number" data-k="display.night.delay" min="-180" max="180" step="1"></div>
</div>
<div class="hint">Uses red and black only. Auto starts 60 minutes after sunset by default and ends at sunrise. A negative delay starts before sunset. A valid clock and location are required.</div>
</div>
</div>
<div class="card">
<h2>Advanced</h2>
@@ -935,6 +942,8 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"display.timeout":return String((st.cfg.display||{}).timeout||15);
case"display.usb.mode":return (st.cfg.display||{}).usb_mode||"button";
case"display.usb.timeout":return String((st.cfg.display||{}).usb_timeout||15);
case"display.night":return (st.cfg.display||{}).night||"auto";
case"display.night.delay":return String((st.cfg.display||{}).night_delay==null?60:st.cfg.display.night_delay);
case"repeat":return r.repeat?"on":"off";
case"flood.max":return String(r.flood_max);
case"flood.max.advert":return String(r.flood_max_advert);
@@ -1124,6 +1133,9 @@ function loadConfig(loadCapture){
var rotationSupported=!!(c.display&&c.display.rotation_supported);
$("#display-rotation-setting").classList.toggle("hide",!rotationSupported);
$('[data-k="display.rotation"]').disabled=!rotationSupported;
var nightSupported=!!(c.display&&c.display.night_supported);
$("#display-night-settings").classList.toggle("hide",!nightSupported);
$$('#display-night-settings [data-k]').forEach(function(el){el.disabled=!nightSupported});
$$('#display-settings option[value="pairing"],#display-settings option[value="button-pairing"]').forEach(function(el){el.hidden=!(c.display&&c.display.pairing);el.disabled=el.hidden});
syncDisplayTimeouts();
loadRxps(c.radio);
@@ -2085,6 +2097,8 @@ var CLI_VERBS=[
mode: 0 = get and set, 1 = get only, 2 = set only
values: enum offered as value completions after `set <key> ` */
var CLI_KEYS=[
["display.night","Color display night mode",0,"auto|on|off"],
["display.night.delay","Night mode delay after sunset in minutes (-180 to 180)",0],
["display.rotation","Display rotation on supported hardware (0 restores the board default)",0,"0|90|180|270"],
["display.inbox","Companion inbox behavior",0,"history|pending|unread"],
["display.mode","Display on battery",0,"off|on|button|pairing|button-pairing|automatic"],
@@ -2282,6 +2296,8 @@ var CLI_COMPANION_VERBS=[
["reboot","Restart the node"]
];
var CLI_COMPANION_KEYS=[
["display.night","Color display night mode",0,"auto|on|off"],
["display.night.delay","Night mode delay after sunset in minutes (-180 to 180)",0],
["lost.reply","Lost-status auto reply to saved private chat contacts",0,"off|no|yes"],
["display.inbox","Companion inbox behavior",0,"history|pending|unread"],
["display.mode","Display on battery",0,"off|on|button|pairing|button-pairing|automatic"],