diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e0894b4f..c8b5382d 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -348,3 +348,4 @@ STR_AUTHOR: "Author" STR_SERIES: "Series" STR_FILE_SIZE: "Size" STR_SLEEP_COVER_OVERLAY: "Sleep Screen Info Overlay" +STR_SYSTEM_INFO: "System Information" diff --git a/lib/hal/HalStorage.cpp b/lib/hal/HalStorage.cpp index e877902f..7cb41433 100644 --- a/lib/hal/HalStorage.cpp +++ b/lib/hal/HalStorage.cpp @@ -4,6 +4,7 @@ #include // need to be included before SdFat.h for compatibility with FS.h's File class #include #include +#include #include @@ -11,6 +12,9 @@ HalStorage HalStorage::instance; +// Local SdFat instance for volume stats only +static SdFat halStorageSdFat; + HalStorage::HalStorage() { storageMutex = xSemaphoreCreateMutex(); assert(storageMutex != nullptr); @@ -54,6 +58,36 @@ bool HalStorage::writeFile(const char* path, const String& content) { bool HalStorage::ensureDirectoryExists(const char* path) { HAL_STORAGE_WRAPPED_CALL(ensureDirectoryExists, path); } +uint64_t HalStorage::sdTotalBytes() const { + StorageLock lock; + // Try to initialize if not already + if (!halStorageSdFat.begin()) return 0; + auto* vol = halStorageSdFat.vol(); + if (!vol) return 0; + return (uint64_t)vol->clusterCount() * vol->bytesPerCluster(); +} + +uint64_t HalStorage::sdUsedBytes() const { + StorageLock lock; + if (!halStorageSdFat.begin()) return 0; + auto* vol = halStorageSdFat.vol(); + if (!vol) return 0; + const int32_t freeClusters = vol->freeClusterCount(); + if (freeClusters < 0) return 0; // error reading FAT + const uint64_t clusterCount = vol->clusterCount(); + uint64_t cappedFreeClusters = freeClusters < 0 ? 0 : (uint64_t)freeClusters; + if (cappedFreeClusters > clusterCount) cappedFreeClusters = clusterCount; + const uint64_t bytesPerCluster = vol->bytesPerCluster(); + return (clusterCount - cappedFreeClusters) * bytesPerCluster; +} + +uint64_t HalStorage::sdFreeBytes() const { + uint64_t total = sdTotalBytes(); + uint64_t used = sdUsedBytes(); + if (total <= used) return 0; + return total - used; +} + class HalFile::Impl { public: Impl(FsFile&& fsFile) : file(std::move(fsFile)) {} diff --git a/lib/hal/HalStorage.h b/lib/hal/HalStorage.h index 58e202d4..65e0a071 100644 --- a/lib/hal/HalStorage.h +++ b/lib/hal/HalStorage.h @@ -45,6 +45,10 @@ class HalStorage { bool openFileForWrite(const char* moduleName, const String& path, HalFile& file); bool removeDir(const char* path); + uint64_t sdTotalBytes() const; + uint64_t sdUsedBytes() const; + uint64_t sdFreeBytes() const; + static HalStorage& getInstance() { return instance; } class StorageLock; // private class, used internally diff --git a/src/SystemStatus.h b/src/SystemStatus.h new file mode 100644 index 00000000..f76ea392 --- /dev/null +++ b/src/SystemStatus.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +// Snapshot of device system status, shared between the web server and the +// System Information activity so both surfaces show consistent data. +struct SystemStatus { + const char* version; + std::string ip; + std::string wifiMode; // "STA", "AP", or "Off" + int rssi; // dBm; 0 when not in STA mode + std::string macAddress; + uint32_t freeHeapBytes; + uint32_t uptimeSeconds; + uint64_t sdTotalBytes; + uint64_t sdUsedBytes; + uint64_t sdFreeBytes; + + static SystemStatus collect() { + SystemStatus s; + s.version = CROSSPOINT_VERSION; + s.freeHeapBytes = ESP.getFreeHeap(); + s.uptimeSeconds = millis() / 1000; + s.macAddress = WiFi.macAddress().c_str(); + s.sdTotalBytes = Storage.sdTotalBytes(); + s.sdUsedBytes = Storage.sdUsedBytes(); + s.sdFreeBytes = Storage.sdFreeBytes(); + + const wifi_mode_t mode = WiFi.getMode(); + const bool isAP = (mode == WIFI_MODE_AP) || (mode == WIFI_MODE_APSTA); + + if (isAP) { + s.wifiMode = "AP"; + s.ip = WiFi.softAPIP().toString().c_str(); + s.rssi = 0; + } else if (WiFi.status() == WL_CONNECTED) { + s.wifiMode = "STA"; + s.ip = WiFi.localIP().toString().c_str(); + s.rssi = WiFi.RSSI(); + } else { + s.wifiMode = "Off"; + s.ip = "-"; + s.rssi = 0; + } + + return s; + } +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 9cdb237c..dff044a0 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,6 +6,7 @@ #include "ButtonRemapActivity.h" #include "CalibreSettingsActivity.h" #include "ClearCacheActivity.h" +#include "SystemInformationActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "LanguageSelectActivity.h" @@ -52,6 +53,7 @@ void SettingsActivity::onEnter() { systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); + systemSettings.push_back(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo)); readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar)); // Reset selection to first category @@ -192,6 +194,9 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::Language: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::SystemInfo: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::None: // Do nothing break; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 9d5778a9..e09c571a 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -21,6 +21,7 @@ enum class SettingAction { ClearCache, CheckForUpdates, Language, + SystemInfo, }; struct SettingInfo { diff --git a/src/activities/settings/SystemInformationActivity.cpp b/src/activities/settings/SystemInformationActivity.cpp new file mode 100644 index 00000000..09aaa4e7 --- /dev/null +++ b/src/activities/settings/SystemInformationActivity.cpp @@ -0,0 +1,94 @@ +#include "SystemInformationActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "SystemStatus.h" +#include "components/UITheme.h" +#include "fontIds.h" + +static std::string formatBytes(uint64_t bytes) { + char buf[16]; + if (bytes >= 1024ULL * 1024 * 1024) { + snprintf(buf, sizeof(buf), "%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } else if (bytes >= 1024ULL * 1024) { + snprintf(buf, sizeof(buf), "%.1f MB", bytes / (1024.0 * 1024.0)); + } else if (bytes >= 1024ULL) { + snprintf(buf, sizeof(buf), "%.1f KB", bytes / 1024.0); + } else { + snprintf(buf, sizeof(buf), "%llu B", static_cast(bytes)); + } + return buf; +} + +void SystemInformationActivity::onEnter() { + Activity::onEnter(); + requestUpdate(); +} + +void SystemInformationActivity::onExit() { Activity::onExit(); } + +void SystemInformationActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + } +} + +void SystemInformationActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SYSTEM_INFO), + CROSSPOINT_VERSION); + + const auto status = SystemStatus::collect(); + + // Layout: label on the left, value right of the midpoint + const int leftX = metrics.verticalSpacing * 3; + const int valueX = pageWidth / 2; + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int startY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 3; + + auto drawRow = [&](int row, const char* label, const std::string& value) { + const int y = startY + row * (lineH + metrics.verticalSpacing); + renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str()); + }; + + // Device + drawRow(0, "Version", status.version); + drawRow(1, "Free heap", std::to_string(status.freeHeapBytes / 1024) + " KB"); + + const uint32_t h = status.uptimeSeconds / 3600; + const uint32_t m = (status.uptimeSeconds % 3600) / 60; + const uint32_t s = status.uptimeSeconds % 60; + char uptimeBuf[16]; + snprintf(uptimeBuf, sizeof(uptimeBuf), "%uh %02um %02us", h, m, s); + drawRow(2, "Uptime", uptimeBuf); + + // SD card + const std::string sdUsed = formatBytes(status.sdUsedBytes) + " / " + formatBytes(status.sdTotalBytes); + drawRow(3, "SD card", status.sdTotalBytes > 0 ? sdUsed : "N/A"); + + // WiFi (shown as-is; "Off" when not connected) + std::string wifiLabel = status.wifiMode; + if (status.rssi != 0) { + wifiLabel += " (" + std::to_string(status.rssi) + " dBm)"; + } + drawRow(4, "WiFi", wifiLabel); + if (status.wifiMode != "Off") { + drawRow(5, "IP address", status.ip); + drawRow(6, "MAC address", status.macAddress); + } else { + drawRow(5, "MAC address", status.macAddress); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/SystemInformationActivity.h b/src/activities/settings/SystemInformationActivity.h new file mode 100644 index 00000000..4beab5af --- /dev/null +++ b/src/activities/settings/SystemInformationActivity.h @@ -0,0 +1,14 @@ +#pragma once + +#include "activities/Activity.h" + +class SystemInformationActivity final : public Activity { + public: + explicit SystemInformationActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("SystemInformation", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index bb090604..64ff4280 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -12,6 +12,7 @@ #include "CrossPointSettings.h" #include "SettingsList.h" +#include "SystemStatus.h" #include "WebDAVHandler.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" @@ -316,16 +317,17 @@ void CrossPointWebServer::handleNotFound() const { } void CrossPointWebServer::handleStatus() const { - // Get correct IP based on AP vs STA mode - const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + const auto status = SystemStatus::collect(); JsonDocument doc; - doc["version"] = CROSSPOINT_VERSION; - doc["ip"] = ipAddr; - doc["mode"] = apMode ? "AP" : "STA"; - doc["rssi"] = apMode ? 0 : WiFi.RSSI(); - doc["freeHeap"] = ESP.getFreeHeap(); - doc["uptime"] = millis() / 1000; + doc["version"] = status.version; + doc["ip"] = status.ip; + doc["mode"] = status.wifiMode; + doc["rssi"] = status.rssi; + doc["freeHeap"] = status.freeHeapBytes; + doc["uptime"] = status.uptimeSeconds; + doc["sdTotal"] = status.sdTotalBytes; + doc["sdUsed"] = status.sdUsedBytes; String json; serializeJson(doc, json); diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index dc1fd0a6..20ac2ef0 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -84,7 +84,6 @@ class CrossPointWebServer { // File scanning void scanFiles(const char* path, const std::function& callback) const; - String formatFileSize(size_t bytes) const; bool isEpubFile(const String& filename) const; // Request handlers diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index e073e6d8..79412c0f 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -124,6 +124,10 @@ Free Memory +
+ SD Card + +
@@ -132,6 +136,14 @@