diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 00ea938210..e2f6876b44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: CI # Run this workflow whenever the build may be affected on: push: - branches: [ main ] + branches: [ main, "voidsec-hub/**" ] paths-ignore: - 'doc/**' - '**.md' diff --git a/README.md b/README.md index 68d8779d0e..9847e2aa8e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,41 @@ +## // CYBERPUNK EDITION + +This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTime. + +| NetRunner | Glitch | Matrix | Badge | +|:-:|:-:|:-:|:-:| +| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | ![Badge](doc/cyberpunk/badge.png) | +| **Launcher** | **Settings** | **Terminal** | **Intrusions** | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | ![Intrusions](doc/cyberpunk/intrusions.png) | +| **Sleep** | **Trackers** | | | +| ![Sleep](doc/cyberpunk/sleep.png) | ![Trackers](doc/cyberpunk/trackers.png) | | | + +What's different from upstream: + +- **NetRunner watch face** (new default): chromatic-aberration time with random glitch bursts plus a guaranteed glitch every minute, HUD frame lines, seconds sweep bar, link/power status, HR + steps, weather, and the Unix epoch in hex. The handle in the bottom-left corner comes from `src/Identity.h`. +- **Matrix app**: digital rain built from ASCII and Cyrillic glyphs. Tap to cycle green, cyan, and magenta palettes. +- **Badge app** (skull icon): your handle and a scannable QR code linking to your GitHub. The screen stays on while it is open. To point it somewhere else, run `python3 tools/badge/generate_badge_qr.py ` (needs `pip install segno`). +- **Intrusion log** (shield icon): every Bluetooth connection that isn't your bonded phone is logged with time, address, duration and what it tried (pairing, failed pairing). If you have a bonded phone, an unknown connection vibrates and pops up an `UNKNOWN LINK` alert. Toggle alerts on/off or wipe the log from the app. Your phone counts as trusted only when it is paired with the PIN (bonded), so pair it that way. +- **Sleep tracking** (moon icon): detects sleep automatically, no button to press, using the open, research-validated methods from [GGIR](https://github.com/wadpac/GGIR): the sleep period is found with HDCZA (van Hees et al. 2018) and sleep vs. wake inside it with sustained inactivity bouts, no forearm angle change over 5° for more than 5 minutes (van Hees et al. 2015). Non-wear uses GGIR's criterion (under 50 mg range on 2 of 3 axes for an hour) plus charging. Shows time asleep, sleep period, time awake, wake-ups, efficiency and average/lowest heart rate for tonight (LIVE while asleep) and the last 7 nights. It uses the accelerometer that is already running for step counting, so it costs no extra battery. Sleep stages (deep/light/REM) are not shown: wrist motion can't tell them apart. Code in `src/components/sleep/`. +- **Tracker detector** (crosshairs icon): spots Bluetooth item trackers that could be following you: AirTags and other Find My accessories in "separated from owner" mode, Samsung SmartTag, Tile, Chipolo, Google Find My Device tags and trackers using the DULT anti-stalking beacon. With *AUTO SCAN ON* the watch listens (passively, it never transmits) for 8 seconds every 5 minutes; a tracker seen in 3+ scans over 30+ minutes raises a `TRACKER NEARBY` alert. While the app is open it scans continuously and lists trackers by signal strength, so you can walk around and home in on one. Your own AirTag near your iPhone is not reported; your own Tile/SmartTag/Chipolo will be. +- **Bluetooth name** is your handle (`VOIDSEC`), set in `src/Identity.h`. Gadgetbridge only auto-detects watches whose name starts with `InfiniTime`, so pair it before flashing this firmware, or temporarily change the name back to pair. +- **Heart rate in the background**: measurement starts at boot and runs every 5 minutes by default, so you never have to open the heart rate app. If no pulse is found for 30 seconds (the watch is off your wrist or on the charger), it backs off and retries every 5 minutes, so even *Cont* mode doesn't run the sensor all day on the nightstand. Change the interval in *Settings → Heart rate*. +- **Neon theme**: cyan and magenta outlined buttons, sharp corners, dark purple surfaces, and recolored launcher tiles, lists, sliders, and switches. +- **Terminal face** recolored as a root shell. +- **Trimmed build**: Paint, Paddle, 2048, Dice, and Metronome apps plus the Analog, PineTimeStyle, Infineat, Casio, and Pride Flag faces are no longer built by default, which frees about 49 KB of flash. Re-enable any of them in `src/displayapp/apps/CMakeLists.txt`. + +### Installing with Gadgetbridge + +1. Grab `pinetime-mcuboot-app-dfu-.zip` (and `infinitime-resources-.zip` for the navigation icons) from the CI artifacts of this branch. +2. On the watch, go to *Settings → Over-the-air* and pick *Till reboot*. Updates and file uploads are refused while it is *Disabled*, and *Till reboot* shuts the door again after the update. +3. In Gadgetbridge, open the zip with *File installer* (or share it to Gadgetbridge) and install. Then install the resources zip the same way. +4. After the watch reboots, go to *Settings → Firmware* and tap *Validate*, otherwise the old firmware comes back on the next reboot. + +> If your watch already has saved settings, pick NetRunner in *Settings → Watch face*. + ## New to InfiniTime? - [Getting started with InfiniTime](doc/gettingStarted/gettingStarted-1.0.md) diff --git a/doc/cyberpunk/badge.png b/doc/cyberpunk/badge.png new file mode 100644 index 0000000000..4949b3e139 Binary files /dev/null and b/doc/cyberpunk/badge.png differ diff --git a/doc/cyberpunk/intrusions.png b/doc/cyberpunk/intrusions.png new file mode 100644 index 0000000000..e47919dc58 Binary files /dev/null and b/doc/cyberpunk/intrusions.png differ diff --git a/doc/cyberpunk/launcher.png b/doc/cyberpunk/launcher.png new file mode 100644 index 0000000000..b309ecaddd Binary files /dev/null and b/doc/cyberpunk/launcher.png differ diff --git a/doc/cyberpunk/matrix.png b/doc/cyberpunk/matrix.png new file mode 100644 index 0000000000..87fe9c6f82 Binary files /dev/null and b/doc/cyberpunk/matrix.png differ diff --git a/doc/cyberpunk/netrunner-glitch.png b/doc/cyberpunk/netrunner-glitch.png new file mode 100644 index 0000000000..12ac0fcdfd Binary files /dev/null and b/doc/cyberpunk/netrunner-glitch.png differ diff --git a/doc/cyberpunk/netrunner.png b/doc/cyberpunk/netrunner.png new file mode 100644 index 0000000000..f046a08e4b Binary files /dev/null and b/doc/cyberpunk/netrunner.png differ diff --git a/doc/cyberpunk/settings.png b/doc/cyberpunk/settings.png new file mode 100644 index 0000000000..8b474d1c48 Binary files /dev/null and b/doc/cyberpunk/settings.png differ diff --git a/doc/cyberpunk/sleep.png b/doc/cyberpunk/sleep.png new file mode 100644 index 0000000000..332ef26804 Binary files /dev/null and b/doc/cyberpunk/sleep.png differ diff --git a/doc/cyberpunk/terminal.png b/doc/cyberpunk/terminal.png new file mode 100644 index 0000000000..e6e2ed5910 Binary files /dev/null and b/doc/cyberpunk/terminal.png differ diff --git a/doc/cyberpunk/trackers.png b/doc/cyberpunk/trackers.png new file mode 100644 index 0000000000..04d5d9d745 Binary files /dev/null and b/doc/cyberpunk/trackers.png differ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index faef4160da..686ce0ce12 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -393,6 +393,11 @@ list(APPEND SOURCE_FILES displayapp/screens/Steps.cpp displayapp/screens/Timer.cpp displayapp/screens/Dice.cpp + displayapp/screens/Matrix.cpp + displayapp/screens/Badge.cpp + displayapp/screens/IntrusionLog.cpp + displayapp/screens/Sleep.cpp + displayapp/screens/Trackers.cpp displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp @@ -430,6 +435,7 @@ list(APPEND SOURCE_FILES displayapp/screens/WatchFacePineTimeStyle.cpp displayapp/screens/WatchFaceCasioStyleG7710.cpp displayapp/screens/WatchFacePrideFlag.cpp + displayapp/screens/WatchFaceNetRunner.cpp ## @@ -463,6 +469,10 @@ list(APPEND SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp + components/ble/TrackerDetector.cpp + components/sleep/SleepAnalysis.cpp + components/sleep/SleepTracker.cpp components/ble/ServiceDiscovery.cpp components/ble/HeartRateService.cpp components/ble/MotionService.cpp @@ -533,6 +543,10 @@ list(APPEND RECOVERY_SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp + components/ble/TrackerDetector.cpp + components/sleep/SleepAnalysis.cpp + components/sleep/SleepTracker.cpp components/ble/ServiceDiscovery.cpp components/ble/NavigationService.cpp components/ble/HeartRateService.cpp @@ -591,6 +605,7 @@ set(INCLUDE_FILES ${CMAKE_CURRENT_BINARY_DIR}/src/Version.h BootloaderVersion.h + Identity.h logging/Logger.h logging/NrfLogger.h displayapp/DisplayApp.h @@ -618,6 +633,12 @@ set(INCLUDE_FILES displayapp/screens/Motion.h displayapp/screens/Timer.h displayapp/screens/Dice.h + displayapp/screens/Matrix.h + displayapp/screens/Badge.h + displayapp/screens/BadgeQr.h + displayapp/screens/IntrusionLog.h + displayapp/screens/Sleep.h + displayapp/screens/Trackers.h displayapp/screens/Alarm.h displayapp/Colors.h displayapp/widgets/Counter.h @@ -653,6 +674,10 @@ set(INCLUDE_FILES components/ble/BatteryInformationService.h components/ble/FSService.h components/ble/ImmediateAlertService.h + components/ble/IntrusionLog.h + components/ble/TrackerDetector.h + components/sleep/SleepAnalysis.h + components/sleep/SleepTracker.h components/ble/ServiceDiscovery.h components/ble/BleClient.h components/ble/HeartRateService.h @@ -795,6 +820,8 @@ add_definitions(-DFREERTOS) add_definitions(-D__STACK_SIZE=1024) add_definitions(-D__HEAP_SIZE=0) add_definitions(-DMYNEWT_VAL_BLE_LL_RFMGMT_ENABLE_TIME=1500) +# Tracker detection scans in crowded places, remember more devices for duplicate filtering +add_definitions(-DMYNEWT_VAL_BLE_LL_NUM_SCAN_DUP_ADVS=32) add_definitions(-DLFS_CONFIG=libs/lfs_config.h) # _sbrk is purposefully not implemented so that builds fail when it is used diff --git a/src/Identity.h b/src/Identity.h new file mode 100644 index 0000000000..c2aaa923af --- /dev/null +++ b/src/Identity.h @@ -0,0 +1,9 @@ +#pragma once + +namespace Pinetime { + namespace Identity { + // Your handle. Shown on the NetRunner watch face and the Badge app, and used as the Bluetooth name. + // Note: companion apps such as Gadgetbridge look for a name starting with "InfiniTime" when pairing a new watch. + static constexpr const char* handle = "VOIDSEC"; + } +} diff --git a/src/components/ble/IntrusionLog.cpp b/src/components/ble/IntrusionLog.cpp new file mode 100644 index 0000000000..468b987d6b --- /dev/null +++ b/src/components/ble/IntrusionLog.cpp @@ -0,0 +1,202 @@ +#include "components/ble/IntrusionLog.h" + +#include +#include +#include "components/fs/FS.h" + +using namespace Pinetime::Controllers; + +IntrusionLog::IntrusionLog(FS& fs) : fs {fs} { +} + +void IntrusionLog::Load() { + FileData data; + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_RDONLY) != LFS_ERR_OK) { + return; + } + const int read = fs.FileRead(&file, reinterpret_cast(&data), sizeof(data)); + fs.FileClose(&file); + if (read != sizeof(data) || data.magic != magic || data.version != fileVersion || data.count > MaxEntries || data.head >= MaxEntries) { + return; + } + enabled = data.enabled != 0; + count = data.count; + head = data.head; + entries = data.entries; +} + +void IntrusionLog::Save() { + FileData data; + taskENTER_CRITICAL(); + data.magic = magic; + data.version = fileVersion; + data.enabled = enabled ? 1 : 0; + data.count = count; + data.head = head; + data.entries = entries; + dirty = false; + taskEXIT_CRITICAL(); + + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) != LFS_ERR_OK) { + dirty = true; + return; + } + fs.FileWrite(&file, reinterpret_cast(&data), sizeof(data)); + fs.FileClose(&file); +} + +void IntrusionLog::SetEnabled(bool enable) { + enabled = enable; + dirty = true; +} + +void IntrusionLog::Clear() { + taskENTER_CRITICAL(); + count = 0; + committed = false; + dirty = true; + taskEXIT_CRITICAL(); +} + +IntrusionLog::Entry IntrusionLog::Get(size_t index) const { + Entry entry {}; + taskENTER_CRITICAL(); + if (index < count) { + entry = entries[(head + MaxEntries - index) % MaxEntries]; + } + taskEXIT_CRITICAL(); + return entry; +} + +void IntrusionLog::FormatAddress(const Entry& entry, char* buffer) { + static constexpr char hex[] = "0123456789ABCDEF"; + // NimBLE stores addresses little-endian, print them the usual way round + for (int i = 0; i < 6; i++) { + const uint8_t byte = entry.address[5 - i]; + *buffer++ = hex[byte >> 4]; + *buffer++ = hex[byte & 0x0F]; + *buffer++ = i < 5 ? ':' : '\0'; + } +} + +const char* IntrusionLog::Describe(const Entry& entry) { + if ((entry.flags & Trusted) != 0) { + return "BONDED"; + } + if ((entry.flags & EncryptFailed) != 0) { + return "PAIR FAILED"; + } + if ((entry.flags & PairAttempt) != 0) { + return "PAIR ATTEMPT"; + } + if ((entry.flags & NoBond) != 0) { + return "UNVERIFIED"; + } + return "UNKNOWN"; +} + +void IntrusionLog::Commit() { + taskENTER_CRITICAL(); + head = (head + 1) % MaxEntries; + count = std::min(count + 1, MaxEntries); + entries[head] = current; + committed = true; + dirty = true; + taskEXIT_CRITICAL(); +} + +void IntrusionLog::OnConnect(const uint8_t* address, uint8_t addressType, bool knownBond, uint32_t time) { + // Nothing to log when disabled, or when the bonded phone reconnects with its identity address + connected = enabled && !knownBond; + pending = connected; + committed = false; + if (!connected) { + return; + } + current.time = time; + current.duration = 0; + std::memcpy(current.address, address, sizeof(current.address)); + current.addressType = addressType; + current.flags = 0; + connectTick = xTaskGetTickCount(); +} + +bool IntrusionLog::OnPairingRequest() { + if (connected) { + Active().flags |= PairAttempt; + } + return false; +} + +bool IntrusionLog::OnEncryptionChange(bool success, bool bonded) { + if (!connected) { + return false; + } + if (success && bonded) { + if (pending) { + // Our own phone: forget about it + connected = false; + pending = false; + } else { + // Encrypted after it was already reported, keep the record but mark it + Active().flags |= Trusted; + dirty = true; + } + return false; + } + if (!success) { + Active().flags |= EncryptFailed; + if (pending) { + pending = false; + current.flags |= Alerted; + Commit(); + return true; + } + dirty = true; + } + return false; +} + +bool IntrusionLog::ClassifyPending(bool hasBond) { + if (!pending) { + return false; + } + pending = false; + if (!hasBond) { + current.flags |= NoBond; + } + const bool alert = hasBond || (current.flags & (EncryptFailed | PairAttempt)) != 0; + if (alert) { + current.flags |= Alerted; + } + Commit(); + return alert; +} + +bool IntrusionLog::Classify(bool hasBond) { + // Give someone typing the passkey on their phone time to finish pairing + if (pending && (current.flags & PairAttempt) != 0) { + return false; + } + return ClassifyPending(hasBond); +} + +bool IntrusionLog::OnDisconnect(bool hasBond) { + if (!connected) { + return false; + } + const uint32_t seconds = (xTaskGetTickCount() - connectTick) / configTICK_RATE_HZ; + const uint16_t duration = static_cast(std::min(std::max(seconds, 1), UINT16_MAX)); + current.duration = duration; + const bool alert = ClassifyPending(hasBond); + if (committed) { + taskENTER_CRITICAL(); + entries[head].duration = duration; + taskEXIT_CRITICAL(); + dirty = true; + } + connected = false; + return alert; +} diff --git a/src/components/ble/IntrusionLog.h b/src/components/ble/IntrusionLog.h new file mode 100644 index 0000000000..bd18a77781 --- /dev/null +++ b/src/components/ble/IntrusionLog.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Pinetime { + namespace Controllers { + class FS; + + // Records Bluetooth connections that don't come from the bonded phone. + // + // A connection is considered trusted when it encrypts with the stored bond. + // Anything else is logged, and raises an alert when a bond exists (so there is + // a known phone to compare against) or when pairing/encryption was attempted. + class IntrusionLog { + public: + static constexpr uint8_t MaxEntries = 16; + + enum Flags : uint8_t { + Trusted = 1 << 0, // Encrypted with the stored bond + EncryptFailed = 1 << 1, + PairAttempt = 1 << 2, // Asked the watch to show a pairing passkey + Alerted = 1 << 3, + NoBond = 1 << 4, // No bonded phone to compare against, can't tell friend from foe + }; + + struct Entry { + uint32_t time; // Local time, seconds since epoch + uint16_t duration; // Seconds connected, 0 while still connected + uint8_t address[6]; + uint8_t addressType; + uint8_t flags; + }; + + explicit IntrusionLog(FS& fs); + + void Load(); + void Save(); + + bool IsEnabled() const { + return enabled; + } + + void SetEnabled(bool enable); + void Clear(); + + bool IsDirty() const { + return dirty; + } + + size_t Count() const { + return count; + } + + // 0 is the newest entry + Entry Get(size_t index) const; + + // "AA:BB:CC:DD:EE:FF", buffer must hold at least 18 bytes + static void FormatAddress(const Entry& entry, char* buffer); + // Short description of what the connection did + static const char* Describe(const Entry& entry); + + // Called from the BLE stack. Return true when an alert should be raised. + void OnConnect(const uint8_t* address, uint8_t addressType, bool knownBond, uint32_t time); + bool OnPairingRequest(); + bool OnEncryptionChange(bool success, bool bonded); + bool OnDisconnect(bool hasBond); + + // Called a few seconds after the connection is established + bool Classify(bool hasBond); + + private: + static constexpr uint32_t magic = 0x4c495356; // "VSIL" + static constexpr uint8_t fileVersion = 1; + static constexpr const char* fileName = "/intrusion.dat"; + + struct FileData { + uint32_t magic; + uint8_t version; + uint8_t enabled; + uint8_t count; + uint8_t head; + std::array entries; + }; + + // Entry for the current connection: kept aside until it's classified, then committed to the log + Entry& Active() { + return committed ? entries[head] : current; + } + + bool ClassifyPending(bool hasBond); + void Commit(); + + FS& fs; + std::array entries {}; + uint8_t head = MaxEntries - 1; + uint8_t count = 0; + bool enabled = true; + bool dirty = false; + Entry current {}; + bool connected = false; // A connection is being tracked + bool pending = false; // The tracked connection isn't classified yet + bool committed = false; // The tracked connection was written to the log + TickType_t connectTick = 0; + }; + } +} diff --git a/src/components/ble/NimbleController.cpp b/src/components/ble/NimbleController.cpp index 5059007ab9..00900cc04d 100644 --- a/src/components/ble/NimbleController.cpp +++ b/src/components/ble/NimbleController.cpp @@ -49,7 +49,8 @@ NimbleController::NimbleController(Pinetime::System::SystemTask& systemTask, heartRateService {*this, heartRateController}, motionService {*this, motionController}, fsService {systemTask, fs}, - serviceDiscovery({¤tTimeClient, &alertNotificationClient}) { + serviceDiscovery({¤tTimeClient, &alertNotificationClient}), + intrusionLogger {fs} { } void nimble_on_reset(int reason) { @@ -132,6 +133,8 @@ void NimbleController::Init() { ASSERT(rc == 0); RestoreBond(); + intrusionLogger.Load(); + LoadTrackerSettings(); StartAdvertising(); } @@ -207,6 +210,17 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { } else { connectionHandle = event->connect.conn_handle; bleController.Connect(); + + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(connectionHandle, &desc) == 0) { + union ble_store_key key; + union ble_store_value value; + memset(&key, 0, sizeof key); + key.sec.peer_addr = desc.peer_id_addr; + const bool knownBond = ble_store_read_peer_sec(&key.sec, &value.sec) == 0; + const auto now = std::chrono::duration_cast(dateTimeController.CurrentDateTime().time_since_epoch()); + intrusionLogger.OnConnect(desc.peer_ota_addr.val, desc.peer_ota_addr.type, knownBond, static_cast(now.count())); + } systemTask.PushMessage(Pinetime::System::Messages::BleConnected); // Service discovery is deferred via systemtask } @@ -221,6 +235,12 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { PersistBond(event->disconnect.conn); } + if (intrusionLogger.OnDisconnect(HasBond())) { + systemTask.PushMessage(Pinetime::System::Messages::OnIntrusion); + } else if (intrusionLogger.IsDirty()) { + systemTask.PushMessage(Pinetime::System::Messages::IntrusionLogChanged); + } + currentTimeClient.Reset(); alertNotificationClient.Reset(); connectionHandle = BLE_HS_CONN_HANDLE_NONE; @@ -258,12 +278,15 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { if (desc.sec_state.bonded) { PersistBond(desc); } + intrusionLogger.OnEncryptionChange(true, desc.sec_state.bonded); NRF_LOG_INFO("new state: encrypted=%d authenticated=%d bonded=%d key_size=%d", desc.sec_state.encrypted, desc.sec_state.authenticated, desc.sec_state.bonded, desc.sec_state.key_size); + } else if (intrusionLogger.OnEncryptionChange(false, false)) { + systemTask.PushMessage(Pinetime::System::Messages::OnIntrusion); } break; @@ -281,6 +304,7 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { * Use the tinycrypt prng here since rand() is predictable. */ NRF_LOG_INFO("Security event : BLE_GAP_EVENT_PASSKEY_ACTION"); + intrusionLogger.OnPairingRequest(); if (event->passkey.params.action == BLE_SM_IOACT_DISP) { struct ble_sm_io pkey = {0}; pkey.action = event->passkey.params.action; @@ -411,6 +435,7 @@ void NimbleController::EnableRadio() { } void NimbleController::DisableRadio() { + StopTrackerScan(); bleController.DisableRadio(); if (bleController.IsConnected()) { ble_gap_terminate(connectionHandle, BLE_ERR_REM_USER_CONN_TERM); @@ -480,6 +505,140 @@ void NimbleController::PersistBond(struct ble_gap_conn_desc& desc) { } } +namespace { + constexpr const char* trackerSettingsFile = "/trackers.dat"; + constexpr int32_t autoScanMilliseconds = 8000; + constexpr int32_t liveScanMilliseconds = 20000; + + uint32_t UptimeSeconds() { + return xTaskGetTickCount() / configTICK_RATE_HZ; + } + + int TrackerScanCallback(struct ble_gap_event* event, void* arg) { + return static_cast(arg)->OnTrackerScanEvent(event); + } +} + +void NimbleController::LoadTrackerSettings() { + lfs_file_t file; + if (fs.FileOpen(&file, trackerSettingsFile, LFS_O_RDONLY) == LFS_ERR_OK) { + uint8_t enabled = 0; + if (fs.FileRead(&file, &enabled, 1) == 1) { + trackerScanEnabled = enabled != 0; + } + fs.FileClose(&file); + } +} + +void NimbleController::SaveTrackerSettings() { + lfs_file_t file; + if (fs.FileOpen(&file, trackerSettingsFile, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) == LFS_ERR_OK) { + const uint8_t enabled = trackerScanEnabled ? 1 : 0; + fs.FileWrite(&file, &enabled, 1); + fs.FileClose(&file); + trackerSettingsDirty = false; + } +} + +void NimbleController::SetTrackerScanEnabled(bool enabled) { + trackerScanEnabled = enabled; + trackerSettingsDirty = true; +} + +void NimbleController::StartTrackerScan(bool live) { + if (!bleController.IsRadioEnabled() || bleController.State() == Ble::FirmwareUpdateStates::Running) { + return; + } + if (trackerScanActive) { + if (!live) { + // A scan is already running + return; + } + // Restart to switch to (or extend) the live scan + ble_gap_disc_cancel(); + trackerScanActive = false; + } + + struct ble_gap_disc_params params {}; + + params.itvl = 160; // 100ms + params.window = live ? 160 : 80; // Continuous while hunting, half the time otherwise + params.passive = 1; // Only listen, never send scan requests + params.filter_duplicates = live ? 0 : 1; // Live needs every report for the signal strength + taskENTER_CRITICAL(); + trackerDetector.BeginScan(); + taskEXIT_CRITICAL(); + const int rc = ble_gap_disc(addrType, live ? liveScanMilliseconds : autoScanMilliseconds, ¶ms, TrackerScanCallback, this); + if (rc == 0) { + trackerScanActive = true; + trackerScanLive = live; + } +} + +void NimbleController::StopTrackerScan() { + if (trackerScanActive) { + ble_gap_disc_cancel(); + trackerScanActive = false; + trackerScanLive = false; + } + EvaluateTrackers(); +} + +void NimbleController::EvaluateTrackers() { + size_t index = 0; + bool alert = false; + taskENTER_CRITICAL(); + alert = trackerDetector.Evaluate(UptimeSeconds(), index); + if (alert) { + lastTrackerAlert = trackerDetector.Get(index); + } + taskEXIT_CRITICAL(); + if (alert) { + systemTask.PushMessage(Pinetime::System::Messages::OnTrackerAlert); + } +} + +size_t NimbleController::TrackerSnapshot(std::array& out) { + taskENTER_CRITICAL(); + const size_t count = trackerDetector.Count(); + for (size_t i = 0; i < count; i++) { + out[i] = trackerDetector.Get(i); + } + taskEXIT_CRITICAL(); + return count; +} + +int NimbleController::OnTrackerScanEvent(ble_gap_event* event) { + switch (event->type) { + case BLE_GAP_EVENT_DISC: { + const auto type = TrackerDetector::Classify(event->disc.data, event->disc.length_data); + if (type != TrackerDetector::Type::None) { + taskENTER_CRITICAL(); + trackerDetector.OnSighting(event->disc.addr.val, type, event->disc.rssi, UptimeSeconds()); + taskEXIT_CRITICAL(); + } + } break; + case BLE_GAP_EVENT_DISC_COMPLETE: + trackerScanActive = false; + trackerScanLive = false; + EvaluateTrackers(); + break; + default: + break; + } + return 0; +} + +bool NimbleController::HasBond() { + int count = 0; + ble_store_util_count(BLE_STORE_OBJ_TYPE_PEER_SEC, &count); + return count > 0; +} + +bool NimbleController::CheckIntrusion() { + return intrusionLogger.Classify(HasBond()); +} + void NimbleController::RestoreBond() { lfs_file_t file_p; union ble_store_value sec, cccd; diff --git a/src/components/ble/NimbleController.h b/src/components/ble/NimbleController.h index 597ef0cc34..c4e1c69034 100644 --- a/src/components/ble/NimbleController.h +++ b/src/components/ble/NimbleController.h @@ -1,6 +1,7 @@ #pragma once #include +#include "Identity.h" #define min // workaround: nimble's min/max macros conflict with libstdc++ #define max @@ -17,6 +18,8 @@ #include "components/ble/FSService.h" #include "components/ble/HeartRateService.h" #include "components/ble/ImmediateAlertService.h" +#include "components/ble/IntrusionLog.h" +#include "components/ble/TrackerDetector.h" #include "components/ble/MusicService.h" #include "components/ble/NavigationService.h" #include "components/ble/ServiceDiscovery.h" @@ -71,6 +74,45 @@ namespace Pinetime { return weatherService; }; + Pinetime::Controllers::IntrusionLog& intrusionLog() { + return intrusionLogger; + }; + + // Classifies the current connection for the intrusion log, returns true if it should raise an alert + bool CheckIntrusion(); + + // Tracker detection. Automatic scans run in the background when enabled, a live scan + // (continuous, for hunting a tag down by signal strength) runs while the Trackers app is open. + static constexpr size_t MaxTrackers = TrackerDetector::MaxTrackers; + + bool IsTrackerScanEnabled() const { + return trackerScanEnabled; + } + + void SetTrackerScanEnabled(bool enabled); + void StartTrackerScan(bool live); + void StopTrackerScan(); + + bool IsTrackerScanning() const { + return trackerScanActive; + } + + size_t TrackerSnapshot(std::array& out); + + TrackerDetector::Tracker LastTrackerAlert() const { + return lastTrackerAlert; + } + + // Drops trackers that went away and raises an alert for one that's following + void EvaluateTrackers(); + void SaveTrackerSettings(); + + bool TrackerSettingsDirty() const { + return trackerSettingsDirty; + } + + int OnTrackerScanEvent(ble_gap_event* event); + uint16_t connHandle(); void NotifyBatteryLevel(uint8_t level); @@ -84,8 +126,9 @@ namespace Pinetime { private: void PersistBond(struct ble_gap_conn_desc& desc); void RestoreBond(); + bool HasBond(); - static constexpr const char* deviceName = "InfiniTime"; + static constexpr const char* deviceName = Pinetime::Identity::handle; Pinetime::System::SystemTask& systemTask; Ble& bleController; DateTime& dateTimeController; @@ -107,6 +150,15 @@ namespace Pinetime { MotionService motionService; FSService fsService; ServiceDiscovery serviceDiscovery; + IntrusionLog intrusionLogger; + + void LoadTrackerSettings(); + TrackerDetector trackerDetector; + TrackerDetector::Tracker lastTrackerAlert {}; + bool trackerScanEnabled = false; + bool trackerSettingsDirty = false; + bool trackerScanActive = false; + bool trackerScanLive = false; uint8_t addrType; uint16_t connectionHandle = BLE_HS_CONN_HANDLE_NONE; diff --git a/src/components/ble/TrackerDetector.cpp b/src/components/ble/TrackerDetector.cpp new file mode 100644 index 0000000000..05d7fd9d81 --- /dev/null +++ b/src/components/ble/TrackerDetector.cpp @@ -0,0 +1,179 @@ +#include "components/ble/TrackerDetector.h" + +#include + +using namespace Pinetime::Controllers; + +namespace { + constexpr uint8_t adTypeUuid16Incomplete = 0x02; + constexpr uint8_t adTypeUuid16Complete = 0x03; + constexpr uint8_t adTypeServiceData16 = 0x16; + constexpr uint8_t adTypeManufacturer = 0xFF; + + constexpr uint16_t companyApple = 0x004C; + constexpr uint8_t appleOfflineFinding = 0x12; + constexpr uint8_t appleSeparatedLength = 0x19; // Full key: separated from the owner. Near the owner it's 0x02. + + constexpr uint16_t uuidSmartTag = 0xFD5A; + constexpr uint16_t uuidTile = 0xFEED; + constexpr uint16_t uuidTileAlt = 0xFEEC; + constexpr uint16_t uuidChipolo = 0xFE33; + constexpr uint16_t uuidEddystone = 0xFEAA; // Google Find My Device network uses frame types 0x40/0x41 + constexpr uint16_t uuidDult = 0xFCB2; + + uint16_t Le16(const uint8_t* p) { + return static_cast(p[0] | (p[1] << 8)); + } + + TrackerDetector::Type FromUuid(uint16_t uuid, const uint8_t* serviceData, uint8_t serviceDataLength) { + switch (uuid) { + case uuidSmartTag: + return TrackerDetector::Type::SmartTag; + case uuidTile: + case uuidTileAlt: + return TrackerDetector::Type::Tile; + case uuidChipolo: + return TrackerDetector::Type::Chipolo; + case uuidDult: + return TrackerDetector::Type::Dult; + case uuidEddystone: + // Plain Eddystone beacons (frame types 0x00-0x30) are not trackers + if (serviceData != nullptr && serviceDataLength >= 1 && (serviceData[0] == 0x40 || serviceData[0] == 0x41)) { + return TrackerDetector::Type::GoogleFind; + } + return TrackerDetector::Type::None; + default: + return TrackerDetector::Type::None; + } + } +} + +TrackerDetector::Type TrackerDetector::Classify(const uint8_t* data, uint8_t length) { + uint8_t offset = 0; + while (offset + 1 < length) { + const uint8_t fieldLength = data[offset]; + if (fieldLength == 0 || offset + 1 + fieldLength > length) { + break; + } + const uint8_t type = data[offset + 1]; + const uint8_t* payload = &data[offset + 2]; + const uint8_t payloadLength = fieldLength - 1; + + if (type == adTypeManufacturer && payloadLength >= 5 && Le16(payload) == companyApple && payload[2] == appleOfflineFinding && + payload[3] == appleSeparatedLength) { + // Bits 4-5 of the status byte tell what kind of Find My device this is + switch ((payload[4] >> 4) & 0x3) { + case 1: + return Type::AirTag; + case 2: + return Type::FindMy; + case 3: + return Type::AirPods; + default: + break; // iPhones, Macs... not trackers + } + } else if (type == adTypeServiceData16 && payloadLength >= 2) { + const Type found = FromUuid(Le16(payload), payload + 2, payloadLength - 2); + if (found != Type::None) { + return found; + } + } else if (type == adTypeUuid16Incomplete || type == adTypeUuid16Complete) { + for (uint8_t i = 0; i + 1 < payloadLength; i += 2) { + const uint16_t uuid = Le16(&payload[i]); + // Service data is needed to recognize Google tags, a bare UUID is not enough + if (uuid != uuidEddystone) { + const Type found = FromUuid(uuid, nullptr, 0); + if (found != Type::None) { + return found; + } + } + } + } + offset += fieldLength + 1; + } + return Type::None; +} + +const char* TrackerDetector::Name(Type type) { + switch (type) { + case Type::AirTag: + return "AirTag"; + case Type::FindMy: + return "Find My tag"; + case Type::AirPods: + return "AirPods"; + case Type::SmartTag: + return "SmartTag"; + case Type::Tile: + return "Tile"; + case Type::Chipolo: + return "Chipolo"; + case Type::GoogleFind: + return "Google tag"; + case Type::Dult: + return "Tracker"; + case Type::None: + break; + } + return "?"; +} + +void TrackerDetector::OnSighting(const uint8_t* address, Type type, int8_t rssi, uint32_t now) { + if (type == Type::None) { + return; + } + Tracker* tracker = nullptr; + for (size_t i = 0; i < count; i++) { + if (std::memcmp(trackers[i].address, address, 6) == 0) { + tracker = &trackers[i]; + break; + } + } + if (tracker == nullptr) { + if (count < MaxTrackers) { + tracker = &trackers[count++]; + } else { + // Full: replace the one not seen for the longest time + tracker = &trackers[0]; + for (size_t i = 1; i < count; i++) { + if (trackers[i].lastSeen < tracker->lastSeen) { + tracker = &trackers[i]; + } + } + } + std::memcpy(tracker->address, address, 6); + tracker->type = type; + tracker->firstSeen = now; + tracker->scans = 0; + tracker->lastScan = static_cast(scanId - 1); + tracker->alerted = false; + } + tracker->rssi = rssi; + tracker->lastSeen = now; + if (tracker->lastScan != scanId) { + tracker->lastScan = scanId; + if (tracker->scans < 255) { + tracker->scans++; + } + } +} + +bool TrackerDetector::Evaluate(uint32_t now, size_t& alertIndex) { + // Forget trackers that went away, keeping the array packed + size_t kept = 0; + for (size_t i = 0; i < count; i++) { + if (now - trackers[i].lastSeen <= ForgetSeconds) { + trackers[kept++] = trackers[i]; + } + } + count = kept; + + for (size_t i = 0; i < count; i++) { + if (!trackers[i].alerted && trackers[i].IsFollowing()) { + trackers[i].alerted = true; + alertIndex = i; + return true; + } + } + return false; +} diff --git a/src/components/ble/TrackerDetector.h b/src/components/ble/TrackerDetector.h new file mode 100644 index 0000000000..b2e62e5dff --- /dev/null +++ b/src/components/ble/TrackerDetector.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +namespace Pinetime { + namespace Controllers { + + // Recognizes Bluetooth item trackers from their advertisements and notices when one keeps + // showing up around the wearer. Plain C++ so it can be tested on a PC. + // + // Only trackers that advertise in a way that can indicate unwanted tracking are counted: + // Apple Find My accessories only when separated from their owner (a tag near its owner is + // just someone's keys), Samsung SmartTag, Tile, Chipolo, Google Find My Device tags and the + // cross-vendor DULT unwanted tracking beacon. + class TrackerDetector { + public: + enum class Type : uint8_t { None, AirTag, FindMy, AirPods, SmartTag, Tile, Chipolo, GoogleFind, Dult }; + + static constexpr uint8_t MaxTrackers = 20; + static constexpr uint32_t FollowSeconds = 30 * 60; // Seen this long... + static constexpr uint8_t FollowScans = 3; // ...in at least this many scans: it's following you + static constexpr uint32_t ForgetSeconds = 20 * 60; // Not seen this long: forget it + + struct Tracker { + uint8_t address[6]; + Type type; + int8_t rssi; // Last signal strength, dBm + uint32_t firstSeen; // Seconds, monotonic + uint32_t lastSeen; + uint16_t lastScan; + uint8_t scans; // Number of separate scans it showed up in + bool alerted; + + uint32_t FollowingSeconds() const { + return lastSeen - firstSeen; + } + + bool IsFollowing() const { + return FollowingSeconds() >= FollowSeconds && scans >= FollowScans; + } + }; + + // Parses raw advertising data + static Type Classify(const uint8_t* data, uint8_t length); + static const char* Name(Type type); + + void BeginScan() { + scanId++; + } + + void OnSighting(const uint8_t* address, Type type, int8_t rssi, uint32_t now); + + // Drops trackers that went away. Returns true when a tracker newly qualifies as following, + // alertIndex is then set to it. + bool Evaluate(uint32_t now, size_t& alertIndex); + + size_t Count() const { + return count; + } + + Tracker Get(size_t index) const { + return trackers[index]; + } + + private: + std::array trackers {}; + size_t count = 0; + uint16_t scanId = 0; + }; + } +} diff --git a/src/components/settings/Settings.h b/src/components/settings/Settings.h index 9133d3fea1..2e7fcd9a55 100644 --- a/src/components/settings/Settings.h +++ b/src/components/settings/Settings.h @@ -367,7 +367,7 @@ namespace Pinetime { WeatherFormat weatherFormat = WeatherFormat::Metric; Notification notificationStatus = Notification::On; - Pinetime::Applications::WatchFace watchFace = Pinetime::Applications::WatchFace::Digital; + Pinetime::Applications::WatchFace watchFace = Pinetime::Applications::WatchFace::NetRunner; ChimesOption chimesOption = ChimesOption::None; PineTimeStyle PTS; @@ -382,7 +382,7 @@ namespace Pinetime { Controllers::BrightnessController::Levels brightLevel = Controllers::BrightnessController::Levels::Medium; bool dfuAndFsEnabledOnBoot = false; - uint16_t heartRateBackgroundPeriod = std::numeric_limits::max(); // Disabled by default + uint16_t heartRateBackgroundPeriod = 5 * 60; // Measure every 5 minutes by default }; SettingsData settings; diff --git a/src/components/sleep/SleepAnalysis.cpp b/src/components/sleep/SleepAnalysis.cpp new file mode 100644 index 0000000000..ab01f534c2 --- /dev/null +++ b/src/components/sleep/SleepAnalysis.cpp @@ -0,0 +1,177 @@ +#include "components/sleep/SleepAnalysis.h" + +#include + +using namespace Pinetime::Controllers::Sleep; + +namespace { + // Median of the valid minutes in a window of RollingMinutes around index, Invalid if too few + uint8_t RollingMedian(const Window& window, uint16_t index) { + uint8_t values[RollingMinutes]; + uint8_t n = 0; + const int32_t half = RollingMinutes / 2; + for (int32_t i = static_cast(index) - half; i <= static_cast(index) + half; i++) { + if (i >= 0 && i < window.Size() && window.IsValid(i)) { + values[n++] = window.Median(i); + } + } + if (n <= RollingMinutes / 2) { + return Invalid; + } + // Insertion sort, at most RollingMinutes values + for (uint8_t i = 1; i < n; i++) { + const uint8_t value = values[i]; + uint8_t j = i; + while (j > 0 && values[j - 1] > value) { + values[j] = values[j - 1]; + j--; + } + values[j] = value; + } + return values[n / 2]; + } + + // Length of the run of equal bits starting at index + uint16_t Run(const uint8_t* bits, uint16_t size, uint16_t index) { + const bool value = GetBit(bits, index); + uint16_t i = index; + while (i < size && GetBit(bits, i) == value) { + i++; + } + return i - index; + } + + void Fill(uint8_t* bits, uint16_t from, uint16_t length, bool value) { + for (uint16_t i = from; i < from + length; i++) { + SetBit(bits, i, value); + } + } +} + +Session Pinetime::Controllers::Sleep::Analyze(const Window& window, const Scratch& scratch) { + const uint16_t size = window.Size(); + Session session; + if (size == 0) { + return session; + } + + // HDCZA threshold: 10th percentile of the rolling median, times 15, kept within 0.13-0.5 degrees + std::fill(scratch.histogram, scratch.histogram + 256, 0); + uint32_t total = 0; + for (uint16_t i = 0; i < size; i++) { + const uint8_t value = RollingMedian(window, i); + if (value != Invalid) { + scratch.histogram[value]++; + total++; + } + } + if (total == 0) { + return session; + } + const uint32_t rank = (total * ThresholdPercentile + 99) / 100; + uint32_t seen = 0; + uint16_t percentile = 0; + for (uint16_t v = 0; v < 256; v++) { + seen += scratch.histogram[v]; + if (seen >= rank) { + percentile = v; + break; + } + } + const uint16_t threshold = std::clamp(percentile * ThresholdMultiplier, ThresholdMin, ThresholdMax); + session.threshold = threshold; + + // Candidate minutes: rolling median below the threshold + uint8_t* bits = scratch.windowBits; + for (uint16_t i = 0; i < size; i++) { + const uint8_t value = RollingMedian(window, i); + SetBit(bits, i, value != Invalid && value < threshold); + } + + // Drop blocks of MinBlockMinutes or less + for (uint16_t i = 0; i < size;) { + const uint16_t run = Run(bits, size, i); + if (GetBit(bits, i) && run <= MinBlockMinutes) { + Fill(bits, i, run, false); + } + i += run; + } + + // Fill gaps shorter than MaxGapMinutes between blocks + for (uint16_t i = 0; i < size;) { + const uint16_t run = Run(bits, size, i); + if (!GetBit(bits, i) && run < MaxGapMinutes && i > 0 && i + run < size) { + Fill(bits, i, run, true); + } + i += run; + } + + // The longest block is the sleep period (the latest one on a tie) + uint16_t bestStart = 0; + uint16_t bestLength = 0; + for (uint16_t i = 0; i < size;) { + const uint16_t run = Run(bits, size, i); + if (GetBit(bits, i) && run >= bestLength) { + bestStart = i; + bestLength = run; + } + i += run; + } + if (bestLength == 0) { + return session; + } + session.start = bestStart; + session.end = bestStart + bestLength - 1; + + // Sustained inactivity bouts: stretches without posture changes of more than 5 minutes, + // including the minutes with the posture changes that bound them + uint8_t* asleep = scratch.asleepBits; + std::fill(asleep, asleep + size / 8 + 1, 0); + uint16_t runStart = 0; + uint16_t runLength = 0; + for (uint16_t i = 0; i <= size; i++) { + const bool quiet = i < size && window.IsValid(i) && !window.PostureChange(i); + if (quiet) { + if (runLength == 0) { + runStart = i; + } + runLength++; + continue; + } + if (runLength >= SustainedInactivityMinutes) { + const uint16_t from = (runStart > 0 && window.IsValid(runStart - 1)) ? runStart - 1 : runStart; + const uint16_t to = (i < size && window.IsValid(i)) ? i : i - 1; + Fill(asleep, from, to - from + 1, true); + } + runLength = 0; + } + + uint16_t awakeRun = 0; + for (uint16_t i = session.start; i <= session.end; i++) { + switch (StageAt(window, scratch, session, i)) { + case Stage::Asleep: + session.asleep++; + awakeRun = 0; + break; + case Stage::Awake: + session.awake++; + if (++awakeRun == WakeUpMinutes && session.wakeUps < 255) { + session.wakeUps++; + } + break; + case Stage::None: + break; + } + } + + session.found = session.asleep >= MinAsleepMinutes; + session.ongoing = (size - 1 - session.end) <= OngoingMinutes; + return session; +} + +Stage Pinetime::Controllers::Sleep::StageAt(const Window& window, const Scratch& scratch, const Session& session, uint16_t index) { + if (index < session.start || index > session.end || !window.IsValid(index)) { + return Stage::None; + } + return GetBit(scratch.asleepBits, index) ? Stage::Asleep : Stage::Awake; +} diff --git a/src/components/sleep/SleepAnalysis.h b/src/components/sleep/SleepAnalysis.h new file mode 100644 index 0000000000..69bf5bbb4c --- /dev/null +++ b/src/components/sleep/SleepAnalysis.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +// Sleep detection from wrist accelerometer data, following the open GGIR methods: +// +// - Sleep period time window: HDCZA (van Hees et al. 2018, Scientific Reports 8:12975). +// The 5 minute rolling median of the absolute change in forearm angle is compared to a +// threshold derived from the person's own data (10th percentile x 15, kept within +// 0.13-0.5 degrees). Still blocks of 30 minutes or less are dropped, gaps under 60 minutes +// are filled and the longest block is the night. +// - Sleep inside that window: sustained inactivity bouts (van Hees et al. 2015, PLoS ONE +// 10:e0142533). Asleep when the forearm angle doesn't change by more than 5 degrees for +// more than 5 minutes, awake otherwise. +// +// The inputs are per minute: the median absolute change of the forearm angle between +// consecutive 5 second epochs, and whether any change exceeded 5 degrees (a posture change). +// Pure functions without FreeRTOS dependencies so they can be tested on a PC. +namespace Pinetime { + namespace Controllers { + namespace Sleep { + static constexpr float DeltaUnit = 0.02f; // Degrees per unit of the stored median angle change + static constexpr uint8_t DeltaMax = 254; // Stored values are capped here + static constexpr uint8_t Invalid = 255; // Watch not worn or no data for that minute + static constexpr float PostureChangeDegrees = 5.0f; + + // GGIR defaults + static constexpr uint8_t RollingMinutes = 5; + static constexpr uint8_t ThresholdPercentile = 10; + static constexpr uint8_t ThresholdMultiplier = 15; + static constexpr uint8_t ThresholdMin = 7; // 0.13 degrees, rounded up to the stored resolution + static constexpr uint8_t ThresholdMax = 25; // 0.50 degrees + static constexpr uint16_t MinBlockMinutes = 30; + static constexpr uint16_t MaxGapMinutes = 60; + static constexpr uint8_t SustainedInactivityMinutes = 5; + + // Ours + static constexpr uint16_t MinAsleepMinutes = 60; // Shorter nights (naps) are ignored + static constexpr uint8_t OngoingMinutes = 5; // Window ending this close to now is still in progress + static constexpr uint8_t WakeUpMinutes = 5; // Awake stretches at least this long count as a wake-up + + enum class Stage : uint8_t { None = 0, Awake = 1, Asleep = 2 }; + + inline bool GetBit(const uint8_t* bits, uint16_t index) { + return (bits[index / 8] & (1 << (index % 8))) != 0; + } + + inline void SetBit(uint8_t* bits, uint16_t index, bool value) { + if (value) { + bits[index / 8] |= (1 << (index % 8)); + } else { + bits[index / 8] &= ~(1 << (index % 8)); + } + } + + // Chronological view over ring buffers: index 0 is the oldest minute, Size() - 1 the newest + class Window { + public: + Window(const uint8_t* medians, const uint8_t* changeBits, uint16_t capacity, uint16_t newest, uint16_t count) + : medians {medians}, changeBits {changeBits}, capacity {capacity}, newest {newest}, count {count} { + } + + uint16_t Size() const { + return count; + } + + // Median absolute angle change in DeltaUnits, or Invalid + uint8_t Median(uint16_t index) const { + return medians[Slot(index)]; + } + + bool IsValid(uint16_t index) const { + return Median(index) != Invalid; + } + + // A change of more than 5 degrees happened during this minute + bool PostureChange(uint16_t index) const { + return GetBit(changeBits, Slot(index)); + } + + private: + uint16_t Slot(uint16_t index) const { + return (newest + capacity + 1 - count + index) % capacity; + } + + const uint8_t* medians; + const uint8_t* changeBits; + uint16_t capacity; + uint16_t newest; + uint16_t count; + }; + + struct Scratch { + uint8_t* windowBits; // Size() / 8 + 1 bytes + uint8_t* asleepBits; // Size() / 8 + 1 bytes + uint16_t* histogram; // 256 entries + }; + + struct Session { + bool found = false; + bool ongoing = false; + uint16_t start = 0; // Window index of the first minute of the sleep period + uint16_t end = 0; // Window index of the last minute + uint16_t asleep = 0; + uint16_t awake = 0; + uint8_t wakeUps = 0; + uint8_t threshold = 0; // HDCZA threshold that was used, DeltaUnits + }; + + Session Analyze(const Window& window, const Scratch& scratch); + + // Only valid after Analyze() with the same scratch + Stage StageAt(const Window& window, const Scratch& scratch, const Session& session, uint16_t index); + } + } +} diff --git a/src/components/sleep/SleepTracker.cpp b/src/components/sleep/SleepTracker.cpp new file mode 100644 index 0000000000..46665eedcb --- /dev/null +++ b/src/components/sleep/SleepTracker.cpp @@ -0,0 +1,302 @@ +#include "components/sleep/SleepTracker.h" + +#include +#include +#include +#include "components/fs/FS.h" + +using namespace Pinetime::Controllers; + +namespace { + constexpr uint32_t sameNightSeconds = 3 * 60 * 60; + constexpr float radiansToDegrees = 57.2957795f; + + uint32_t Distance(uint32_t a, uint32_t b) { + return a > b ? a - b : b - a; + } +} + +SleepTracker::SleepTracker(FS& fs) : fs {fs} { + mutex = xSemaphoreCreateMutex(); + xSemaphoreGive(mutex); + medians.fill(Sleep::Invalid); +} + +void SleepTracker::Load() { + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_RDONLY) != LFS_ERR_OK) { + return; + } + uint32_t header[2] = {}; + bool ok = fs.FileRead(&file, reinterpret_cast(header), sizeof(header)) == sizeof(header); + const uint8_t version = header[1] & 0xFF; + const uint8_t storedCount = (header[1] >> 8) & 0xFF; + ok = ok && header[0] == magic && version == fileVersion && storedCount <= MaxNights; + if (ok) { + ok = fs.FileRead(&file, reinterpret_cast(history.data()), sizeof(history)) == sizeof(history); + } + fs.FileClose(&file); + historyCount = ok ? storedCount : 0; +} + +void SleepTracker::Save() { + xSemaphoreTake(mutex, portMAX_DELAY); + dirty = false; + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) == LFS_ERR_OK) { + const uint32_t header[2] = {magic, static_cast(fileVersion) | (static_cast(historyCount) << 8)}; + fs.FileWrite(&file, reinterpret_cast(header), sizeof(header)); + fs.FileWrite(&file, reinterpret_cast(history.data()), sizeof(history)); + fs.FileClose(&file); + } else { + dirty = true; + } + xSemaphoreGive(mutex); +} + +void SleepTracker::OnMotion(int16_t x, int16_t y, int16_t z, uint32_t localMinute, bool offWrist, uint8_t heartRate) { + if (!started) { + started = true; + currentMinute = localMinute; + } else if (localMinute != currentMinute) { + xSemaphoreTake(mutex, portMAX_DELAY); + if (localMinute > currentMinute && localMinute - currentMinute <= Minutes) { + CloseMinute(); + AdvanceTo(localMinute); + } else { + // The clock was set backwards or jumped a long way: start over + count = 0; + stillMinutes = 0; + currentMinute = localMinute; + } + xSemaphoreGive(mutex); + deltaCount = 0; + minutePostureChange = false; + minuteOffWrist = false; + minuteHeartRate = 0; + minuteHasSamples = false; + } + + // Range of each axis during this minute, for non-wear detection + const std::array sample {x, y, z}; + for (size_t axis = 0; axis < 3; axis++) { + minuteMin[axis] = minuteHasSamples ? std::min(minuteMin[axis], sample[axis]) : sample[axis]; + minuteMax[axis] = minuteHasSamples ? std::max(minuteMax[axis], sample[axis]) : sample[axis]; + } + minuteHasSamples = true; + minuteOffWrist = minuteOffWrist || offWrist; + if (heartRate != 0) { + minuteHeartRate = heartRate; + } + + // Forearm angle from the 5 second average, like GGIR's anglez + sumX += x; + sumY += y; + sumZ += z; + if (++samples < SamplesPerEpoch) { + return; + } + const float meanX = static_cast(sumX) / SamplesPerEpoch; + const float meanY = static_cast(sumY) / SamplesPerEpoch; + const float meanZ = static_cast(sumZ) / SamplesPerEpoch; + sumX = sumY = sumZ = 0; + samples = 0; + + const float angle = std::atan2(meanZ, std::sqrt(meanX * meanX + meanY * meanY)) * radiansToDegrees; + if (haveAngle) { + const float change = std::fabs(angle - previousAngle); + if (change > Sleep::PostureChangeDegrees) { + minutePostureChange = true; + } + if (deltaCount < EpochsPerMinute) { + deltas[deltaCount++] = static_cast(std::min(std::lround(change / Sleep::DeltaUnit), static_cast(Sleep::DeltaMax))); + } + } + previousAngle = angle; + haveAngle = true; +} + +void SleepTracker::Push(uint8_t median, bool postureChange) { + newest = (newest + 1) % Minutes; + medians[newest] = median; + Sleep::SetBit(changeBits.data(), newest, postureChange); + count = std::min(count + 1, Minutes); +} + +void SleepTracker::MarkInvalid(uint16_t minutes) { + for (uint16_t i = 0; i < minutes && i < count; i++) { + medians[(newest + Minutes - i) % Minutes] = Sleep::Invalid; + } +} + +void SleepTracker::CloseMinute() { + uint8_t median = Sleep::Invalid; + if (!minuteOffWrist && deltaCount > 0) { + std::nth_element(deltas.begin(), deltas.begin() + deltaCount / 2, deltas.begin() + deltaCount); + median = deltas[deltaCount / 2]; + } + + // Non-wear (GGIR): range under 50mg on at least 2 of the 3 axes for 60 minutes + if (minuteHasSamples) { + auto quietAxes = [&]() { + uint8_t quiet = 0; + for (size_t axis = 0; axis < 3; axis++) { + quiet += (stillMax[axis] - stillMin[axis]) < NonWearRange ? 1 : 0; + } + return quiet; + }; + if (stillMinutes > 0) { + for (size_t axis = 0; axis < 3; axis++) { + stillMin[axis] = std::min(stillMin[axis], minuteMin[axis]); + stillMax[axis] = std::max(stillMax[axis], minuteMax[axis]); + } + } + if (stillMinutes == 0 || quietAxes() < 2) { + // Start a new still stretch with this minute + stillMin = minuteMin; + stillMax = minuteMax; + stillMinutes = quietAxes() >= 2 ? 1 : 0; + } else { + stillMinutes++; + } + } else { + stillMinutes = 0; + } + + Push(median, minutePostureChange); + if (stillMinutes == NonWearMinutes) { + MarkInvalid(NonWearMinutes); + } else if (stillMinutes > NonWearMinutes) { + medians[newest] = Sleep::Invalid; + } + + if (minuteHeartRate != 0) { + auto& slot = heartRates[(currentMinute / 10) % HeartRateSlots]; + slot.sum += minuteHeartRate; + slot.samples++; + } +} + +void SleepTracker::AdvanceTo(uint32_t minute) { + for (uint32_t m = currentMinute + 1; m <= minute; m++) { + if (m % 10 == 0) { + heartRates[(m / 10) % HeartRateSlots] = {}; + } + if (m != minute) { + // Minutes without samples (shouldn't happen, the system task runs all the time) + Push(Sleep::Invalid, false); + stillMinutes = 0; + } + } + currentMinute = minute; +} + +Pinetime::Controllers::Sleep::Window SleepTracker::MakeWindow() const { + return Sleep::Window(medians.data(), changeBits.data(), Minutes, newest, count); +} + +Pinetime::Controllers::Sleep::Scratch SleepTracker::MakeScratch() { + return {windowBits.data(), asleepBits.data(), histogram.data()}; +} + +void SleepTracker::Build(const Sleep::Session& session, Night& night) { + const Sleep::Window window = MakeWindow(); + const Sleep::Scratch scratch = MakeScratch(); + // Window index i is the minute (currentMinute - count + i) + const uint32_t firstMinute = currentMinute - count; + + night = {}; + night.start = (firstMinute + session.start) * 60; + night.inBed = session.end - session.start + 1; + night.asleep = session.asleep; + night.awake = session.awake; + night.wakeUps = session.wakeUps; + night.ongoing = session.ongoing ? 1 : 0; + night.epochs = std::min((night.inBed + EpochMinutes - 1) / EpochMinutes, MaxEpochs); + + for (uint8_t epoch = 0; epoch < night.epochs; epoch++) { + uint8_t awake = 0; + uint8_t asleep = 0; + for (uint8_t m = 0; m < EpochMinutes; m++) { + const uint16_t index = session.start + epoch * EpochMinutes + m; + switch (Sleep::StageAt(window, scratch, session, index)) { + case Sleep::Stage::Awake: + awake++; + break; + case Sleep::Stage::Asleep: + asleep++; + break; + case Sleep::Stage::None: + break; + } + } + Sleep::Stage stage = Sleep::Stage::None; + if (awake > asleep) { + stage = Sleep::Stage::Awake; + } else if (asleep > 0) { + stage = Sleep::Stage::Asleep; + } + night.stages[epoch / 4] |= static_cast(stage) << ((epoch % 4) * 2); + } + + uint32_t sum = 0; + uint8_t slots = 0; + uint8_t minimum = 255; + const uint32_t from = (firstMinute + session.start) / 10; + const uint32_t to = (firstMinute + session.end) / 10; + for (uint32_t s = from; s <= to && s - from < HeartRateSlots; s++) { + const auto& slot = heartRates[s % HeartRateSlots]; + if (slot.samples == 0) { + continue; + } + const uint8_t average = slot.sum / slot.samples; + sum += average; + slots++; + minimum = std::min(minimum, average); + } + if (slots > 0) { + night.avgHeartRate = sum / slots; + night.minHeartRate = minimum; + } +} + +bool SleepTracker::Live(Night& night) { + xSemaphoreTake(mutex, portMAX_DELAY); + const Sleep::Session session = Sleep::Analyze(MakeWindow(), MakeScratch()); + if (session.found) { + Build(session, night); + } + xSemaphoreGive(mutex); + return session.found; +} + +void SleepTracker::Finalize() { + Night night; + if (!Live(night) || night.ongoing != 0) { + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + if (historyCount > 0 && Distance(history[0].start, night.start) < sameNightSeconds) { + // Same night seen again. Only take it if it grew: once the start scrolls out of the + // 24h window the analysis sees a truncated night that must not replace the full one. + if (night.inBed > history[0].inBed) { + history[0] = night; + dirty = true; + } + } else if (historyCount == 0 || night.start > history[0].start) { + for (size_t i = MaxNights - 1; i > 0; i--) { + history[i] = history[i - 1]; + } + history[0] = night; + historyCount = std::min(historyCount + 1, MaxNights); + dirty = true; + } + xSemaphoreGive(mutex); +} + +SleepTracker::Night SleepTracker::History(size_t index) const { + if (index >= historyCount) { + return {}; + } + return history[index]; +} diff --git a/src/components/sleep/SleepTracker.h b/src/components/sleep/SleepTracker.h new file mode 100644 index 0000000000..42d8b588e4 --- /dev/null +++ b/src/components/sleep/SleepTracker.h @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "components/sleep/SleepAnalysis.h" + +namespace Pinetime { + namespace Controllers { + class FS; + + // Records the forearm angle every 5 seconds, keeps per-minute angle statistics for the last + // 24 hours, detects sleep from them (see SleepAnalysis.h) and keeps a short history of nights + // on the filesystem. + class SleepTracker { + public: + static constexpr uint16_t Minutes = 24 * 60; + static constexpr uint8_t EpochMinutes = 5; // Resolution of the stored hypnogram + static constexpr uint8_t MaxEpochs = 192; // 16 hours + static constexpr uint8_t MaxNights = 7; + + struct Night { + uint32_t start; // Local time, seconds since epoch + uint16_t inBed; // Minutes in the sleep period + uint16_t asleep; // Minutes + uint16_t awake; + uint8_t wakeUps; + uint8_t avgHeartRate; // 0 when unknown + uint8_t minHeartRate; + uint8_t epochs; + uint8_t ongoing; + uint8_t reserved; + std::array stages; // 2 bits per epoch, Sleep::Stage values + + Sleep::Stage EpochStage(uint8_t epoch) const { + return static_cast((stages[epoch / 4] >> ((epoch % 4) * 2)) & 0x3); + } + }; + + explicit SleepTracker(FS& fs); + + void Load(); + void Save(); + + bool IsDirty() const { + return dirty; + } + + // Called by the system task for every accelerometer sample (10Hz) + void OnMotion(int16_t x, int16_t y, int16_t z, uint32_t localMinute, bool offWrist, uint8_t heartRate); + + // Called by the system task every hour: stores the last night once it's over + void Finalize(); + + // Current or most recent session in the last 24 hours, false if none + bool Live(Night& night); + + size_t HistoryCount() const { + return historyCount; + } + + // 0 is the most recent stored night + Night History(size_t index) const; + + private: + static constexpr uint16_t HeartRateSlots = Minutes / 10; + static constexpr uint8_t SamplesPerEpoch = 50; // 5 seconds at 10Hz + static constexpr uint8_t EpochsPerMinute = 12; + static constexpr int16_t NonWearRange = 50; // mg: less than this on 2 of 3 axes... + static constexpr uint16_t NonWearMinutes = 60; // ...for this long means the watch isn't worn (GGIR) + static constexpr uint32_t magic = 0x50454c53; // "SLEP" + static constexpr uint8_t fileVersion = 2; + static constexpr const char* fileName = "/sleep.dat"; + + void CloseMinute(); + void AdvanceTo(uint32_t minute); + void Push(uint8_t median, bool postureChange); + void MarkInvalid(uint16_t minutes); + Sleep::Window MakeWindow() const; + Sleep::Scratch MakeScratch(); + void Build(const Sleep::Session& session, Night& night); + + FS& fs; + SemaphoreHandle_t mutex; + + // Per minute, ring buffers + std::array medians; + std::array changeBits {}; + uint16_t newest = Minutes - 1; + uint16_t count = 0; + + // Analysis scratch space + std::array windowBits {}; + std::array asleepBits {}; + std::array histogram {}; + + struct HeartRateSlot { + uint16_t sum; + uint8_t samples; + }; + + std::array heartRates {}; + + uint32_t currentMinute = 0; + bool started = false; + + // Current 5 second epoch + int32_t sumX = 0; + int32_t sumY = 0; + int32_t sumZ = 0; + uint8_t samples = 0; + bool haveAngle = false; + float previousAngle = 0; + + // Current minute + std::array deltas {}; + uint8_t deltaCount = 0; + bool minutePostureChange = false; + bool minuteOffWrist = false; + uint8_t minuteHeartRate = 0; + std::array minuteMin {}; + std::array minuteMax {}; + bool minuteHasSamples = false; + + // Non-wear detection: range of each axis since the start of the current still stretch + std::array stillMin {}; + std::array stillMax {}; + uint16_t stillMinutes = 0; + + std::array history {}; + uint8_t historyCount = 0; + bool dirty = false; + }; + } +} diff --git a/src/displayapp/DisplayApp.cpp b/src/displayapp/DisplayApp.cpp index 84fa603622..043c78dbd8 100644 --- a/src/displayapp/DisplayApp.cpp +++ b/src/displayapp/DisplayApp.cpp @@ -611,7 +611,7 @@ void DisplayApp::LoadScreen(Apps app, DisplayApp::FullRefreshDirections directio currentScreen = std::make_unique(settingsController); break; case Apps::SettingHeartRate: - currentScreen = std::make_unique(settingsController); + currentScreen = std::make_unique(settingsController, heartRateController); break; case Apps::SettingDisplay: currentScreen = std::make_unique(settingsController); diff --git a/src/displayapp/InfiniTimeTheme.cpp b/src/displayapp/InfiniTimeTheme.cpp index 6795647e1b..0247923745 100644 --- a/src/displayapp/InfiniTimeTheme.cpp +++ b/src/displayapp/InfiniTimeTheme.cpp @@ -59,7 +59,7 @@ static void basic_init() { style_init_reset(&style_box); lv_style_set_bg_opa(&style_box, LV_STATE_DEFAULT, LV_OPA_COVER); - lv_style_set_radius(&style_box, LV_STATE_DEFAULT, 10); + lv_style_set_radius(&style_box, LV_STATE_DEFAULT, 3); lv_style_set_value_font(&style_box, LV_STATE_DEFAULT, theme.font_normal); style_init_reset(&style_label_white); @@ -67,7 +67,12 @@ static void basic_init() { lv_style_set_text_color(&style_label_white, LV_STATE_DISABLED, LV_COLOR_GRAY); style_init_reset(&style_btn); - lv_style_set_radius(&style_btn, LV_STATE_DEFAULT, 10); + lv_style_set_radius(&style_btn, LV_STATE_DEFAULT, 3); + lv_style_set_border_width(&style_btn, LV_STATE_DEFAULT, 2); + lv_style_set_border_color(&style_btn, LV_STATE_DEFAULT, Colors::neonCyan); + lv_style_set_border_color(&style_btn, LV_STATE_CHECKED, Colors::neonMagenta); + lv_style_set_border_color(&style_btn, LV_STATE_PRESSED, Colors::neonYellow); + lv_style_set_border_color(&style_btn, LV_STATE_DISABLED, Colors::bgAlt); lv_style_set_bg_opa(&style_btn, LV_STATE_DEFAULT, LV_OPA_COVER); lv_style_set_bg_color(&style_btn, LV_STATE_DEFAULT, Colors::bg); lv_style_set_bg_color(&style_btn, LV_STATE_CHECKED, Colors::highlight); @@ -89,7 +94,7 @@ static void basic_init() { style_init_reset(&style_scrollbar); lv_style_set_bg_opa(&style_scrollbar, LV_STATE_DEFAULT, LV_OPA_COVER); lv_style_set_radius(&style_scrollbar, LV_STATE_DEFAULT, LV_RADIUS_CIRCLE); - lv_style_set_bg_color(&style_scrollbar, LV_STATE_DEFAULT, LV_COLOR_WHITE); + lv_style_set_bg_color(&style_scrollbar, LV_STATE_DEFAULT, Colors::neonCyan); lv_style_set_size(&style_scrollbar, LV_STATE_DEFAULT, LV_HOR_RES / 80); lv_style_set_pad_right(&style_scrollbar, LV_STATE_DEFAULT, LV_HOR_RES / 60); @@ -135,15 +140,15 @@ static void basic_init() { style_init_reset(&style_slider_knob); lv_style_set_bg_opa(&style_slider_knob, LV_STATE_DEFAULT, LV_OPA_COVER); - lv_style_set_bg_color(&style_slider_knob, LV_STATE_DEFAULT, LV_COLOR_RED); - lv_style_set_border_color(&style_slider_knob, LV_STATE_DEFAULT, LV_COLOR_WHITE); + lv_style_set_bg_color(&style_slider_knob, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_style_set_border_color(&style_slider_knob, LV_STATE_DEFAULT, Colors::neonCyan); lv_style_set_border_width(&style_slider_knob, LV_STATE_DEFAULT, 6); lv_style_set_radius(&style_slider_knob, LV_STATE_DEFAULT, LV_RADIUS_CIRCLE); lv_style_set_pad_all(&style_slider_knob, LV_STATE_DEFAULT, 10); lv_style_set_pad_all(&style_slider_knob, LV_STATE_PRESSED, 14); style_init_reset(&style_arc_indic); - lv_style_set_line_color(&style_arc_indic, LV_STATE_DEFAULT, Colors::lightGray); + lv_style_set_line_color(&style_arc_indic, LV_STATE_DEFAULT, Colors::neonCyan); lv_style_set_line_width(&style_arc_indic, LV_STATE_DEFAULT, LV_DPX(25)); lv_style_set_line_rounded(&style_arc_indic, LV_STATE_DEFAULT, true); @@ -156,11 +161,11 @@ static void basic_init() { lv_style_reset(&style_arc_knob); lv_style_set_radius(&style_arc_knob, LV_STATE_DEFAULT, LV_RADIUS_CIRCLE); lv_style_set_bg_opa(&style_arc_knob, LV_STATE_DEFAULT, LV_OPA_COVER); - lv_style_set_bg_color(&style_arc_knob, LV_STATE_DEFAULT, LV_COLOR_WHITE); + lv_style_set_bg_color(&style_arc_knob, LV_STATE_DEFAULT, Colors::neonMagenta); lv_style_set_pad_all(&style_arc_knob, LV_STATE_DEFAULT, LV_DPX(5)); style_init_reset(&style_table_cell); - lv_style_set_border_color(&style_table_cell, LV_STATE_DEFAULT, LV_COLOR_GRAY); + lv_style_set_border_color(&style_table_cell, LV_STATE_DEFAULT, Colors::dimCyan); lv_style_set_border_width(&style_table_cell, LV_STATE_DEFAULT, 1); lv_style_set_border_side(&style_table_cell, LV_STATE_DEFAULT, LV_BORDER_SIDE_FULL); lv_style_set_pad_left(&style_table_cell, LV_STATE_DEFAULT, 5); @@ -188,7 +193,7 @@ static void basic_init() { lv_style_set_scale_end_line_width(&style_lmeter, LV_STATE_DEFAULT, LV_DPX(7)); style_init_reset(&style_chart_serie); - lv_style_set_line_color(&style_chart_serie, LV_STATE_DEFAULT, LV_COLOR_WHITE); + lv_style_set_line_color(&style_chart_serie, LV_STATE_DEFAULT, Colors::neonCyan); lv_style_set_line_width(&style_chart_serie, LV_STATE_DEFAULT, 4); lv_style_set_size(&style_chart_serie, LV_STATE_DEFAULT, 4); lv_style_set_bg_opa(&style_chart_serie, LV_STATE_DEFAULT, 0); diff --git a/src/displayapp/InfiniTimeTheme.h b/src/displayapp/InfiniTimeTheme.h index 57680e8738..493a326f37 100644 --- a/src/displayapp/InfiniTimeTheme.h +++ b/src/displayapp/InfiniTimeTheme.h @@ -5,15 +5,23 @@ namespace Colors { static constexpr lv_color_t deepOrange = LV_COLOR_MAKE(0xff, 0x40, 0x0); static constexpr lv_color_t orange = LV_COLOR_MAKE(0xff, 0xb0, 0x0); - static constexpr lv_color_t green = LV_COLOR_MAKE(0x0, 0xb0, 0x0); - static constexpr lv_color_t blue = LV_COLOR_MAKE(0x0, 0x50, 0xff); + static constexpr lv_color_t green = LV_COLOR_MAKE(0x0, 0xd2, 0x6a); + static constexpr lv_color_t blue = LV_COLOR_MAKE(0x7a, 0x2c, 0xff); static constexpr lv_color_t lightGray = LV_COLOR_MAKE(0xb0, 0xb0, 0xb0); static constexpr lv_color_t gray = LV_COLOR_MAKE(0x50, 0x50, 0x50); - static constexpr lv_color_t bg = LV_COLOR_MAKE(0x5d, 0x69, 0x7e); - static constexpr lv_color_t bgAlt = LV_COLOR_MAKE(0x38, 0x38, 0x38); - static constexpr lv_color_t bgDark = LV_COLOR_MAKE(0x18, 0x18, 0x18); - static constexpr lv_color_t highlight = green; + // Cyberpunk neon palette + static constexpr lv_color_t neonCyan = LV_COLOR_MAKE(0x05, 0xd9, 0xe8); + static constexpr lv_color_t neonMagenta = LV_COLOR_MAKE(0xff, 0x2a, 0x6d); + static constexpr lv_color_t neonYellow = LV_COLOR_MAKE(0xfc, 0xee, 0x0a); + static constexpr lv_color_t neonGreen = LV_COLOR_MAKE(0x39, 0xff, 0x14); + static constexpr lv_color_t neonPurple = LV_COLOR_MAKE(0x9d, 0x4e, 0xdd); + static constexpr lv_color_t dimCyan = LV_COLOR_MAKE(0x0a, 0x4a, 0x55); + + static constexpr lv_color_t bg = LV_COLOR_MAKE(0x2d, 0x1b, 0x4e); + static constexpr lv_color_t bgAlt = LV_COLOR_MAKE(0x1a, 0x10, 0x2e); + static constexpr lv_color_t bgDark = LV_COLOR_MAKE(0x0c, 0x07, 0x14); + static constexpr lv_color_t highlight = neonMagenta; }; /** diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 25926edc40..79f5f58d5d 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -4,6 +4,11 @@ #include "displayapp/screens/Alarm.h" #include "displayapp/screens/Dice.h" +#include "displayapp/screens/Matrix.h" +#include "displayapp/screens/Badge.h" +#include "displayapp/screens/IntrusionLog.h" +#include "displayapp/screens/Sleep.h" +#include "displayapp/screens/Trackers.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" @@ -15,6 +20,7 @@ #include "displayapp/screens/WatchFacePineTimeStyle.h" #include "displayapp/screens/WatchFaceTerminal.h" #include "displayapp/screens/WatchFacePrideFlag.h" +#include "displayapp/screens/WatchFaceNetRunner.h" namespace Pinetime { namespace Applications { diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index d440b598d1..ffbf23dee5 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -30,6 +30,11 @@ namespace Pinetime { Steps, Dice, Weather, + Matrix, + Badge, + IntrusionLog, + Sleep, + Trackers, PassKey, QuickSettings, Settings, @@ -56,6 +61,7 @@ namespace Pinetime { Infineat, CasioStyleG7710, PrideFlag, + NetRunner, }; template diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 93196ed6a0..88d498b38b 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -1,20 +1,26 @@ if(DEFINED ENABLE_USERAPPS) set(USERAPP_TYPES ${ENABLE_USERAPPS} CACHE STRING "List of user apps to build into the firmware") else () - set(DEFAULT_USER_APP_TYPES "Apps::StopWatch") + set(DEFAULT_USER_APP_TYPES "Apps::Matrix") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Badge") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::IntrusionLog") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Sleep") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Trackers") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Steps") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::HeartRate") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Music") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paint") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paddle") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Twos") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Dice") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Metronome") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Navigation") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Calculator") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Weather") + # Trimmed from the cyberpunk build, re-enable by uncommenting + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paint") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paddle") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Twos") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Dice") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Metronome") #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Motion") set(USERAPP_TYPES "${DEFAULT_USER_APP_TYPES}" CACHE STRING "List of user apps to build into the firmware") endif () @@ -22,13 +28,15 @@ endif () if(DEFINED ENABLE_WATCHFACES) set(WATCHFACE_TYPES ${ENABLE_WATCHFACES} CACHE STRING "List of watch faces to build into the firmware") else() - set(DEFAULT_WATCHFACE_TYPES "WatchFace::Digital") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Analog") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PineTimeStyle") + set(DEFAULT_WATCHFACE_TYPES "WatchFace::NetRunner") set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Terminal") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Infineat") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::CasioStyleG7710") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PrideFlag") + set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Digital") + # Trimmed from the cyberpunk build, re-enable by uncommenting + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Analog") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PineTimeStyle") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Infineat") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::CasioStyleG7710") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PrideFlag") set(WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}" CACHE STRING "List of watch faces to build into the firmware") endif() diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 3223b76b56..de496434b8 100644 --- a/src/displayapp/fonts/fonts.json +++ b/src/displayapp/fonts/fonts.json @@ -7,7 +7,7 @@ }, { "file": "FontAwesome5-Solid+Brands+Regular.woff", - "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" + "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed, 0xf120, 0xf54c, 0xf186, 0xf05b" } ], "bpp": 2, diff --git a/src/displayapp/screens/Badge.cpp b/src/displayapp/screens/Badge.cpp new file mode 100644 index 0000000000..92503c882a --- /dev/null +++ b/src/displayapp/screens/Badge.cpp @@ -0,0 +1,41 @@ +#include "displayapp/screens/Badge.h" + +#include +#include "Identity.h" +#include "displayapp/InfiniTimeTheme.h" +#include "displayapp/screens/BadgeQr.h" + +using namespace Pinetime::Applications::Screens; + +Badge::Badge(System::SystemTask& systemTask) : wakeLock(systemTask) { + // Keep the screen on while someone is scanning the code + wakeLock.Lock(); + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_fmt(title, "// %s", Pinetime::Identity::handle); + lv_obj_align(title, nullptr, LV_ALIGN_IN_TOP_MID, 0, 4); + + const lv_coord_t qrSize = BadgeQr::image.header.w; + + lv_obj_t* frame = lv_obj_create(lv_scr_act(), nullptr); + lv_obj_set_size(frame, qrSize + 8, qrSize + 8); + lv_obj_align(frame, nullptr, LV_ALIGN_CENTER, 0, 2); + lv_obj_set_style_local_bg_opa(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, LV_OPA_TRANSP); + lv_obj_set_style_local_radius(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_border_width(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 2); + lv_obj_set_style_local_border_color(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + + lv_obj_t* qr = lv_img_create(lv_scr_act(), nullptr); + lv_img_set_src(qr, &BadgeQr::image); + lv_obj_align(qr, frame, LV_ALIGN_CENTER, 0, 0); + + lv_obj_t* caption = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(caption, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_label_set_text_static(caption, BadgeQr::caption); + lv_obj_align(caption, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -4); +} + +Badge::~Badge() { + lv_obj_clean(lv_scr_act()); +} diff --git a/src/displayapp/screens/Badge.h b/src/displayapp/screens/Badge.h new file mode 100644 index 0000000000..024c1d6c23 --- /dev/null +++ b/src/displayapp/screens/Badge.h @@ -0,0 +1,38 @@ +#pragma once + +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "systemtask/SystemTask.h" +#include "systemtask/WakeLock.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Badge : public Screen { + public: + explicit Badge(System::SystemTask& systemTask); + ~Badge() override; + + private: + System::WakeLock wakeLock; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Badge; + static constexpr const char* icon = Screens::Symbols::skull; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Badge(*controllers.systemTask); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/BadgeQr.h b/src/displayapp/screens/BadgeQr.h new file mode 100644 index 0000000000..0c11f8fc98 --- /dev/null +++ b/src/displayapp/screens/BadgeQr.h @@ -0,0 +1,191 @@ +// Generated by tools/badge/generate_badge_qr.py, do not edit by hand +// URL: https://github.com/CypherNova1337 +#pragma once + +#include + +namespace Pinetime { + namespace Applications { + namespace Screens { + namespace BadgeQr { + static constexpr const char* url = "https://github.com/CypherNova1337"; + static constexpr const char* caption = "gh/CypherNova1337"; + + static const uint8_t map[] = { + 0xe8, 0xd9, 0x05, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + static const lv_img_dsc_t image = { + {LV_IMG_CF_INDEXED_1BIT, 0, 0, 165, 165}, + 3473, + map, + }; + } + } + } +} diff --git a/src/displayapp/screens/Calculator.cpp b/src/displayapp/screens/Calculator.cpp index a1f093830c..c8c152357e 100644 --- a/src/displayapp/screens/Calculator.cpp +++ b/src/displayapp/screens/Calculator.cpp @@ -59,6 +59,13 @@ void Calculator::OnButtonEvent(lv_obj_t* obj, lv_event_t event) { } void Calculator::HandleInput() { + // A press between or outside the buttons leaves LVGL's "active" button on the previous key, + // which would replay it. Only accept presses that actually landed on a button. + const auto* ext = static_cast(lv_obj_get_ext_attr(buttonMatrix)); + if (ext->btn_id_pr == LV_BTNMATRIX_BTN_NONE) { + return; + } + const char* buttonText = lv_btnmatrix_get_active_btn_text(buttonMatrix); if (buttonText == nullptr) { @@ -188,14 +195,15 @@ void Calculator::HandleInput() { break; case '=': - equalSignPressedBefore = true; Eval(); - // If the operation is ' ' then we move the value to the result. - // We reset the input after this. - // This seems more convenient. - if (operation == ' ') { - ResetInput(); + if (error == Error::None) { + // Show the answer on the main line, like a regular calculator. + // The next digit starts a new number, the next operator keeps working on the answer. + value = result; + operation = ' '; + UpdateOperation(); } + equalSignPressedBefore = true; break; } diff --git a/src/displayapp/screens/IntrusionLog.cpp b/src/displayapp/screens/IntrusionLog.cpp new file mode 100644 index 0000000000..8910976bd4 --- /dev/null +++ b/src/displayapp/screens/IntrusionLog.cpp @@ -0,0 +1,164 @@ +#include "displayapp/screens/IntrusionLog.h" + +#include +#include +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + void ButtonEventHandler(lv_obj_t* object, lv_event_t event) { + auto* screen = static_cast(object->user_data); + screen->OnButtonEvent(object, event); + } + + lv_obj_t* CreateButton(lv_obj_t** label, const char* text, void* userData) { + lv_obj_t* button = lv_btn_create(lv_scr_act(), nullptr); + button->user_data = userData; + lv_obj_set_event_cb(button, ButtonEventHandler); + lv_obj_set_style_local_pad_all(button, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 0); + *label = lv_label_create(button, nullptr); + lv_label_set_text_static(*label, text); + return button; + } +} + +IntrusionLog::IntrusionLog(Controllers::IntrusionLog& log) : log {log} { + // The display task keeps the SPI flash awake, so this is a safe moment to persist pending changes + if (log.IsDirty()) { + log.Save(); + } + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(title, "// INTRUSIONS"); + lv_obj_set_pos(title, 4, 2); + + labelPage = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelPage, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonPurple); + + buttonToggle = CreateButton(&labelToggle, "", this); + lv_obj_set_size(buttonToggle, 148, 40); + lv_obj_set_pos(buttonToggle, 2, 30); + + lv_obj_t* labelWipe; + buttonWipe = CreateButton(&labelWipe, "WIPE", this); + lv_obj_set_size(buttonWipe, 82, 40); + lv_obj_set_pos(buttonWipe, 156, 30); + + labelEmpty = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelEmpty, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); + lv_label_set_long_mode(labelEmpty, LV_LABEL_LONG_BREAK); + lv_obj_set_width(labelEmpty, 232); + lv_label_set_text_static(labelEmpty, "No intrusions\nlogged.\n\nPair your phone\nwith the PIN to\nenable alerts."); + lv_obj_set_pos(labelEmpty, 4, 84); + + for (uint8_t i = 0; i < entriesPerPage; i++) { + const lv_coord_t y = 80 + i * 80; + labelStatus[i] = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_pos(labelStatus[i], 4, y); + labelDetails[i] = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(labelDetails[i], true); + lv_obj_set_pos(labelDetails[i], 4, y + 24); + } + + UpdateToggle(); + ShowPage(); +} + +IntrusionLog::~IntrusionLog() { + if (log.IsDirty()) { + log.Save(); + } + lv_obj_clean(lv_scr_act()); +} + +uint8_t IntrusionLog::PageCount() const { + const size_t count = log.Count(); + return count == 0 ? 1 : (count + entriesPerPage - 1) / entriesPerPage; +} + +void IntrusionLog::UpdateToggle() { + const bool enabled = log.IsEnabled(); + lv_label_set_text_static(labelToggle, enabled ? "ALERTS ON" : "ALERTS OFF"); + lv_obj_set_style_local_text_color(labelToggle, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); + lv_obj_set_style_local_border_color(buttonToggle, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); +} + +void IntrusionLog::ShowPage() { + lv_label_set_text_fmt(labelPage, "%d/%d", page + 1, PageCount()); + lv_obj_align(labelPage, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 2); + lv_obj_set_hidden(labelEmpty, log.Count() != 0); + + for (uint8_t i = 0; i < entriesPerPage; i++) { + const size_t index = page * entriesPerPage + i; + const bool visible = index < log.Count(); + lv_obj_set_hidden(labelStatus[i], !visible); + lv_obj_set_hidden(labelDetails[i], !visible); + if (!visible) { + continue; + } + + const auto entry = log.Get(index); + const bool alerted = (entry.flags & Controllers::IntrusionLog::Alerted) != 0; + lv_obj_set_style_local_text_color(labelStatus[i], + LV_LABEL_PART_MAIN, + LV_STATE_DEFAULT, + alerted ? Colors::neonMagenta : Colors::lightGray); + + char duration[8]; + if (entry.duration == 0) { + snprintf(duration, sizeof(duration), "LIVE"); + } else if (entry.duration < 60) { + snprintf(duration, sizeof(duration), "%ds", entry.duration); + } else if (entry.duration < 3600) { + snprintf(duration, sizeof(duration), "%dm", entry.duration / 60); + } else { + snprintf(duration, sizeof(duration), "%dh", entry.duration / 3600); + } + lv_label_set_text_fmt(labelStatus[i], "%-13s%5s", Controllers::IntrusionLog::Describe(entry), duration); + + const std::chrono::sys_seconds time {std::chrono::seconds {entry.time}}; + const auto days = std::chrono::floor(time); + const std::chrono::year_month_day date {days}; + const std::chrono::hh_mm_ss clock {time - days}; + + char address[18]; + Controllers::IntrusionLog::FormatAddress(entry, address); + lv_label_set_text_fmt(labelDetails[i], + "#fcee0a %02u-%02u %02d:%02d#\n#05d9e8 %s#", + static_cast(date.month()), + static_cast(date.day()), + static_cast(clock.hours().count()), + static_cast(clock.minutes().count()), + address); + } +} + +void IntrusionLog::OnButtonEvent(lv_obj_t* object, lv_event_t event) { + if (event != LV_EVENT_CLICKED) { + return; + } + if (object == buttonToggle) { + log.SetEnabled(!log.IsEnabled()); + UpdateToggle(); + } else if (object == buttonWipe) { + log.Clear(); + page = 0; + ShowPage(); + } +} + +bool IntrusionLog::OnTouchEvent(TouchEvents event) { + if (event == TouchEvents::SwipeUp && page + 1 < PageCount()) { + page++; + ShowPage(); + return true; + } + if (event == TouchEvents::SwipeDown && page > 0) { + page--; + ShowPage(); + return true; + } + return false; +} diff --git a/src/displayapp/screens/IntrusionLog.h b/src/displayapp/screens/IntrusionLog.h new file mode 100644 index 0000000000..f8ecdba18f --- /dev/null +++ b/src/displayapp/screens/IntrusionLog.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/ble/IntrusionLog.h" +#include "systemtask/SystemTask.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class IntrusionLog : public Screen { + public: + explicit IntrusionLog(Controllers::IntrusionLog& log); + ~IntrusionLog() override; + + bool OnTouchEvent(TouchEvents event) override; + void OnButtonEvent(lv_obj_t* object, lv_event_t event); + + private: + static constexpr uint8_t entriesPerPage = 2; + + void UpdateToggle(); + void ShowPage(); + uint8_t PageCount() const; + + Controllers::IntrusionLog& log; + uint8_t page = 0; + + lv_obj_t* labelPage; + lv_obj_t* buttonToggle; + lv_obj_t* labelToggle; + lv_obj_t* buttonWipe; + lv_obj_t* labelEmpty; + std::array labelStatus; + std::array labelDetails; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::IntrusionLog; + static constexpr const char* icon = Screens::Symbols::shieldAlt; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::IntrusionLog(controllers.systemTask->nimble().intrusionLog()); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/List.cpp b/src/displayapp/screens/List.cpp index 264b4fc98b..029bffb403 100644 --- a/src/displayapp/screens/List.cpp +++ b/src/displayapp/screens/List.cpp @@ -44,8 +44,11 @@ List::List(uint8_t screenID, static constexpr int btnHeight = (LV_HOR_RES_MAX - ((MAXLISTITEMS - 1) * innerPad)) / MAXLISTITEMS; itemApps[i] = lv_btn_create(container, nullptr); - lv_obj_set_style_local_radius(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, btnHeight / 3); + lv_obj_set_style_local_radius(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 3); lv_obj_set_style_local_bg_color(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_set_style_local_border_side(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, LV_BORDER_SIDE_LEFT); + lv_obj_set_style_local_border_width(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 4); + lv_obj_set_style_local_border_color(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); lv_obj_set_width(itemApps[i], LV_HOR_RES - 8); lv_obj_set_height(itemApps[i], btnHeight); lv_obj_set_event_cb(itemApps[i], ButtonEventHandler); @@ -54,7 +57,7 @@ List::List(uint8_t screenID, lv_obj_set_style_local_clip_corner(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, true); lv_obj_t* icon = lv_label_create(itemApps[i], nullptr); - lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_YELLOW); + lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); lv_label_set_text_static(icon, applications[i].icon); lv_label_set_long_mode(icon, LV_LABEL_LONG_CROP); lv_label_set_align(icon, LV_LABEL_ALIGN_CENTER); diff --git a/src/displayapp/screens/Matrix.cpp b/src/displayapp/screens/Matrix.cpp new file mode 100644 index 0000000000..7a9a972d75 --- /dev/null +++ b/src/displayapp/screens/Matrix.cpp @@ -0,0 +1,142 @@ +#include "displayapp/screens/Matrix.h" + +#include +#include + +using namespace Pinetime::Applications::Screens; + +namespace { + // Printable ASCII plus the Cyrillic block, both of which exist in jetbrains_mono_bold_20. + // '#' is excluded because it is the recolor escape character. + constexpr char asciiGlyphs[] = "0123456789ABCDEFXZ$%&*+<>=?@"; + + char* AppendGlyph(char* out, uint16_t glyph) { + if (glyph < 0x80) { + *out++ = static_cast(glyph); + } else { + *out++ = static_cast(0xC0 | (glyph >> 6)); + *out++ = static_cast(0x80 | (glyph & 0x3F)); + } + return out; + } + + char* AppendColor(char* out, const char* color) { + *out++ = '#'; + for (uint8_t i = 0; i < 6; i++) { + *out++ = color[i]; + } + *out++ = ' '; + return out; + } +} + +Matrix::Matrix(Controllers::DateTime& dateTimeController) { + rngState = static_cast(dateTimeController.CurrentDateTime().time_since_epoch().count()) | 1; + + lv_obj_set_style_local_bg_color(lv_scr_act(), LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_BLACK); + + for (uint8_t i = 0; i < nbColumns; i++) { + Column& column = columns[i]; + ResetColumn(column, true); + for (auto& glyph : column.glyphs) { + glyph = RandomGlyph(); + } + column.label = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(column.label, true); + lv_obj_set_style_local_text_line_space(column.label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_pos(column.label, i * columnWidth, 0); + RenderColumn(column); + } + + taskRefresh = lv_task_create(RefreshTaskCallback, 60, LV_TASK_PRIO_MID, this); +} + +Matrix::~Matrix() { + lv_task_del(taskRefresh); + lv_obj_clean(lv_scr_act()); +} + +uint32_t Matrix::NextRandom() { + // xorshift32 + rngState ^= rngState << 13; + rngState ^= rngState >> 17; + rngState ^= rngState << 5; + return rngState; +} + +uint16_t Matrix::RandomGlyph() { + const uint32_t r = NextRandom(); + if (r % 3 == 0) { + return static_cast(asciiGlyphs[(r >> 2) % (sizeof(asciiGlyphs) - 1)]); + } + return 0x410 + ((r >> 2) % 0x40); +} + +void Matrix::ResetColumn(Column& column, bool initial) { + const uint32_t r = NextRandom(); + column.trail = 4 + r % 7; + column.speed = 1 + (r >> 4) % 3; + // Start above the screen so drops enter at staggered times + column.head = initial ? -static_cast((r >> 8) % 16) : -static_cast((r >> 8) % 8); +} + +void Matrix::RenderColumn(Column& column) { + const Palette& palette = palettes[paletteIndex]; + char* out = column.text.data(); + for (int8_t row = 0; row < nbRows; row++) { + const int8_t distance = column.head - row; + if (distance < 0 || distance >= column.trail) { + *out++ = ' '; + } else { + const char* color = distance == 0 ? palette.head : (distance < 3 ? palette.bright : palette.dim); + out = AppendColor(out, color); + out = AppendGlyph(out, column.glyphs[row]); + *out++ = '#'; + } + if (row < nbRows - 1) { + *out++ = '\n'; + } + } + *out = '\0'; + lv_label_set_text_static(column.label, column.text.data()); +} + +void Matrix::Refresh() { + tick++; + for (auto& column : columns) { + bool changed = false; + + // Glyphs inside the trail flicker every now and then + if (NextRandom() % 4 == 0) { + column.glyphs[NextRandom() % nbRows] = RandomGlyph(); + changed = true; + } + + if (tick % column.speed == 0) { + column.head++; + if (column.head - column.trail >= nbRows) { + ResetColumn(column, false); + } + // The new head always gets a fresh glyph + if (column.head >= 0 && column.head < nbRows) { + column.glyphs[column.head] = RandomGlyph(); + } + changed = true; + } + + if (changed) { + RenderColumn(column); + } + } +} + +bool Matrix::OnTouchEvent(TouchEvents event) { + if (event == TouchEvents::Tap) { + paletteIndex = (paletteIndex + 1) % palettes.size(); + for (auto& column : columns) { + RenderColumn(column); + } + return true; + } + return false; +} diff --git a/src/displayapp/screens/Matrix.h b/src/displayapp/screens/Matrix.h new file mode 100644 index 0000000000..ce0e68cf36 --- /dev/null +++ b/src/displayapp/screens/Matrix.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/datetime/DateTimeController.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Matrix : public Screen { + public: + explicit Matrix(Controllers::DateTime& dateTimeController); + ~Matrix() override; + + void Refresh() override; + bool OnTouchEvent(TouchEvents event) override; + + private: + static constexpr uint8_t nbColumns = 20; + static constexpr uint8_t nbRows = 10; + static constexpr uint8_t columnWidth = 12; + static constexpr uint8_t rowHeight = 24; + // Worst case per row: "#rrggbb " + 2 byte glyph + "#\n" + static constexpr uint16_t bufferSize = nbRows * 12 + 1; + + struct Column { + int8_t head; + uint8_t trail; + uint8_t speed; + std::array glyphs; + std::array text; + lv_obj_t* label; + }; + + struct Palette { + const char* head; + const char* bright; + const char* dim; + }; + + static constexpr std::array palettes {{ + {"ffffff", "39ff14", "0b6b1a"}, + {"ffffff", "05d9e8", "0a4a55"}, + {"fcee0a", "ff2a6d", "5a1030"}, + }}; + + uint32_t NextRandom(); + uint16_t RandomGlyph(); + void ResetColumn(Column& column, bool initial); + void RenderColumn(Column& column); + + std::array columns; + uint32_t rngState; + uint8_t tick = 0; + uint8_t paletteIndex = 0; + lv_task_t* taskRefresh; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Matrix; + static constexpr const char* icon = Screens::Symbols::terminal; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Matrix(controllers.dateTimeController); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/Sleep.cpp b/src/displayapp/screens/Sleep.cpp new file mode 100644 index 0000000000..0ad8338914 --- /dev/null +++ b/src/displayapp/screens/Sleep.cpp @@ -0,0 +1,217 @@ +#include "displayapp/screens/Sleep.h" + +#include +#include +#include +#include +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; +using Night = Pinetime::Controllers::SleepTracker::Night; +using Stage = Pinetime::Controllers::Sleep::Stage; + +namespace { + constexpr uint32_t sameNightSeconds = 3 * 60 * 60; + constexpr const char* weekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}; + + struct LocalTime { + unsigned month; + unsigned day; + unsigned weekday; + int hour; + int minute; + }; + + LocalTime ToLocal(uint32_t seconds) { + const std::chrono::sys_seconds time {std::chrono::seconds {seconds}}; + const auto days = std::chrono::floor(time); + const std::chrono::year_month_day date {days}; + const std::chrono::hh_mm_ss clock {time - days}; + return {static_cast(date.month()), + static_cast(date.day()), + std::chrono::weekday {days}.c_encoding(), + static_cast(clock.hours().count()), + static_cast(clock.minutes().count())}; + } + + lv_obj_t* CreateLabel(lv_color_t color, lv_coord_t x, lv_coord_t y) { + lv_obj_t* label = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, color); + lv_obj_set_pos(label, x, y); + return label; + } +} + +Sleep::Sleep(Controllers::SleepTracker& tracker) : tracker {tracker} { + haveLive = tracker.Live(live); + if (haveLive && tracker.HistoryCount() > 0) { + const uint32_t stored = tracker.History(0).start; + const uint32_t distance = stored > live.start ? stored - live.start : live.start - stored; + // The stored copy of this night is older than what the tracker sees right now + if (distance < sameNightSeconds) { + historyOffset = 1; + } + } + + lv_obj_t* title = CreateLabel(Colors::neonMagenta, 4, 2); + lv_label_set_text_static(title, "// SLEEP"); + + labelPage = CreateLabel(Colors::neonPurple, 0, 2); + labelDate = CreateLabel(Colors::neonYellow, 4, 28); + labelLive = CreateLabel(Colors::neonMagenta, 0, 28); + lv_label_set_text_static(labelLive, "LIVE"); + lv_obj_align(labelLive, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 28); + + labelAsleep = CreateLabel(Colors::neonCyan, 2, 52); + lv_obj_set_style_local_text_font(labelAsleep, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &jetbrains_mono_42); + labelAsleepCaption = CreateLabel(Colors::lightGray, 4, 92); + lv_label_set_text_static(labelAsleepCaption, "ASLEEP"); + + labelTimes = CreateLabel(Colors::neonYellow, 0, 56); + lv_label_set_align(labelTimes, LV_LABEL_ALIGN_RIGHT); + + // Hypnogram: awake at the top, asleep at the bottom, not worn in the middle + graphAxis = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(graphAxis, true); + lv_obj_set_style_local_text_line_space(graphAxis, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, -6); + lv_label_set_text_static(graphAxis, "#ff2a6d W#\n\n#9d4edd S#"); + lv_obj_set_pos(graphAxis, 2, graphTop - 10); + + graph = lv_line_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_line_color(graph, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_obj_set_style_local_line_width(graph, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, 3); + lv_obj_set_pos(graph, graphX, graphTop); + + labelStats = CreateLabel(LV_COLOR_WHITE, 4, 168); + lv_label_set_recolor(labelStats, true); + + labelEmpty = CreateLabel(Colors::lightGray, 4, 40); + lv_label_set_long_mode(labelEmpty, LV_LABEL_LONG_BREAK); + lv_obj_set_width(labelEmpty, 232); + lv_label_set_text_static(labelEmpty, + "No sleep detected\n" + "yet.\n\n" + "Wear the watch to\n" + "bed, the night\n" + "shows up here."); + + ShowPage(); +} + +Sleep::~Sleep() { + lv_obj_clean(lv_scr_act()); +} + +uint8_t Sleep::PageCount() const { + const uint8_t pages = (haveLive ? 1 : 0) + tracker.HistoryCount() - historyOffset; + return std::max(pages, 1); +} + +bool Sleep::NightForPage(uint8_t index, Night& night) const { + if (haveLive) { + if (index == 0) { + night = live; + return true; + } + index--; + } + const size_t historyIndex = index + historyOffset; + if (historyIndex >= tracker.HistoryCount()) { + return false; + } + night = tracker.History(historyIndex); + return true; +} + +void Sleep::ShowPage() { + Night night; + const bool found = NightForPage(page, night); + + lv_label_set_text_fmt(labelPage, "%d/%d", page + 1, PageCount()); + lv_obj_align(labelPage, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 2); + + lv_obj_set_hidden(labelEmpty, found); + for (lv_obj_t* obj : {labelDate, labelAsleep, labelAsleepCaption, labelTimes, graph, graphAxis, labelStats}) { + lv_obj_set_hidden(obj, !found); + } + lv_obj_set_hidden(labelLive, !found || night.ongoing == 0); + if (!found) { + return; + } + + const uint32_t end = night.start + night.inBed * 60; + const LocalTime wake = ToLocal(end); + const LocalTime fellAsleep = ToLocal(night.start); + + lv_label_set_text_fmt(labelDate, "%s %02u-%02u", weekdays[wake.weekday % 7], wake.month, wake.day); + lv_label_set_text_fmt(labelAsleep, "%d:%02d", night.asleep / 60, night.asleep % 60); + if (night.ongoing != 0) { + lv_label_set_text_fmt(labelTimes, "%02d:%02d\n--:--", fellAsleep.hour, fellAsleep.minute); + } else { + lv_label_set_text_fmt(labelTimes, "%02d:%02d\n%02d:%02d", fellAsleep.hour, fellAsleep.minute, wake.hour, wake.minute); + } + lv_obj_align(labelTimes, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 56); + + DrawGraph(night); + + const unsigned efficiency = night.inBed == 0 ? 0 : (night.asleep * 100U) / night.inBed; + char heartRate[8]; + char lowest[8]; + if (night.avgHeartRate != 0) { + snprintf(heartRate, sizeof(heartRate), "%d", night.avgHeartRate); + snprintf(lowest, sizeof(lowest), "%d", night.minHeartRate); + } else { + snprintf(heartRate, sizeof(heartRate), "--"); + snprintf(lowest, sizeof(lowest), "--"); + } + lv_label_set_text_fmt(labelStats, + "#05d9e8 IN BED# %d:%02d #ff2a6d HR# %s\n" + "#ff2a6d AWAKE# %d:%02d #fcee0a LOW# %s\n" + "#9d4edd WAKES# %-4d #fcee0a EFF# %u%%", + night.inBed / 60, + night.inBed % 60, + heartRate, + night.awake / 60, + night.awake % 60, + lowest, + night.wakeUps, + efficiency); +} + +void Sleep::DrawGraph(const Night& night) { + const uint8_t epochs = std::max(night.epochs, 1); + uint16_t points = 0; + for (uint8_t epoch = 0; epoch < night.epochs; epoch++) { + lv_coord_t y; + switch (night.EpochStage(epoch)) { + case Stage::Awake: + y = 0; + break; + case Stage::Asleep: + y = 2 * rowHeight; + break; + default: + y = rowHeight; + break; + } + const lv_coord_t x1 = static_cast(epoch * graphWidth / epochs); + const lv_coord_t x2 = static_cast((epoch + 1) * graphWidth / epochs); + graphPoints[points++] = {x1, y}; + graphPoints[points++] = {x2, y}; + } + lv_line_set_points(graph, graphPoints.data(), points); +} + +bool Sleep::OnTouchEvent(TouchEvents event) { + if (event == TouchEvents::SwipeUp && page + 1 < PageCount()) { + page++; + ShowPage(); + return true; + } + if (event == TouchEvents::SwipeDown && page > 0) { + page--; + ShowPage(); + return true; + } + return false; +} diff --git a/src/displayapp/screens/Sleep.h b/src/displayapp/screens/Sleep.h new file mode 100644 index 0000000000..246a4fbbbd --- /dev/null +++ b/src/displayapp/screens/Sleep.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/sleep/SleepTracker.h" +#include "systemtask/SystemTask.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Sleep : public Screen { + public: + explicit Sleep(Controllers::SleepTracker& tracker); + ~Sleep() override; + + bool OnTouchEvent(TouchEvents event) override; + + private: + static constexpr lv_coord_t graphX = 18; + static constexpr lv_coord_t graphWidth = 218; + static constexpr lv_coord_t graphTop = 126; + static constexpr lv_coord_t rowHeight = 18; + + uint8_t PageCount() const; + bool NightForPage(uint8_t page, Controllers::SleepTracker::Night& night) const; + void ShowPage(); + void DrawGraph(const Controllers::SleepTracker::Night& night); + + Controllers::SleepTracker& tracker; + Controllers::SleepTracker::Night live {}; + bool haveLive = false; + uint8_t historyOffset = 0; // History entries hidden because the live night replaces them + uint8_t page = 0; + + std::array graphPoints {}; + + lv_obj_t* labelPage; + lv_obj_t* labelDate; + lv_obj_t* labelLive; + lv_obj_t* labelAsleep; + lv_obj_t* labelAsleepCaption; + lv_obj_t* labelTimes; + lv_obj_t* graph; + lv_obj_t* graphAxis; + lv_obj_t* labelStats; + lv_obj_t* labelEmpty; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Sleep; + static constexpr const char* icon = Screens::Symbols::moon; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Sleep(controllers.systemTask->sleep()); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/Symbols.h b/src/displayapp/screens/Symbols.h index 058b2d06e9..e9c462222a 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,6 +42,9 @@ namespace Pinetime { static constexpr const char* sleep = "\xEE\xBD\x84"; static constexpr const char* calculator = "\xEF\x87\xAC"; static constexpr const char* backspace = "\xEF\x95\x9A"; + static constexpr const char* terminal = "\xEF\x84\xA0"; // 0xf120 + static constexpr const char* skull = "\xEF\x95\x8C"; // 0xf54c + static constexpr const char* crosshairs = "\xEF\x81\x9B"; // 0xf05b // fontawesome_weathericons.c // static constexpr const char* sun = "\xEF\x86\x85"; diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 2392f3be3e..b6ab02862a 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -80,7 +80,7 @@ std::unique_ptr SystemInfo::CreateScreen1() { lv_obj_t* label = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(label, true); lv_label_set_text_fmt(label, - "#FFFF00 InfiniTime#\n\n" + "#ff2a6d InfiniTime#\n#05d9e8 // CYBERPUNK#\n" "#808080 Version# %ld.%ld.%ld\n" "#808080 Short Ref# %s\n" "#808080 Build date#\n" diff --git a/src/displayapp/screens/Tile.cpp b/src/displayapp/screens/Tile.cpp index 45f715b55a..8cdede5e07 100644 --- a/src/displayapp/screens/Tile.cpp +++ b/src/displayapp/screens/Tile.cpp @@ -69,7 +69,12 @@ Tile::Tile(uint8_t screenID, lv_obj_set_size(btnm1, LV_HOR_RES - 16, LV_VER_RES - 60); lv_obj_align(btnm1, nullptr, LV_ALIGN_CENTER, 0, 10); - lv_obj_set_style_local_radius(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, 20); + lv_obj_set_style_local_radius(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, 4); + lv_obj_set_style_local_text_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_obj_set_style_local_text_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_PRESSED, Colors::neonYellow); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_PRESSED, Colors::neonYellow); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, Colors::bgAlt); lv_obj_set_style_local_bg_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::bgAlt); lv_obj_set_style_local_bg_opa(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, LV_OPA_50); lv_obj_set_style_local_bg_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, Colors::bgDark); diff --git a/src/displayapp/screens/Trackers.cpp b/src/displayapp/screens/Trackers.cpp new file mode 100644 index 0000000000..62d25e0d33 --- /dev/null +++ b/src/displayapp/screens/Trackers.cpp @@ -0,0 +1,178 @@ +#include "displayapp/screens/Trackers.h" + +#include +#include +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; +using Tracker = Pinetime::Controllers::TrackerDetector::Tracker; +using Detector = Pinetime::Controllers::TrackerDetector; + +namespace { + void ButtonEventHandler(lv_obj_t* object, lv_event_t event) { + auto* screen = static_cast(object->user_data); + screen->OnButtonEvent(object, event); + } + + // Signal strength as 0-5 bars + uint8_t Bars(int8_t rssi) { + if (rssi >= -55) { + return 5; + } + if (rssi >= -65) { + return 4; + } + if (rssi >= -75) { + return 3; + } + if (rssi >= -85) { + return 2; + } + return 1; + } +} + +Trackers::Trackers(Controllers::NimbleController& nimble) : nimble {nimble} { + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(title, "// TRACKERS"); + lv_obj_set_pos(title, 4, 2); + + labelScanning = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelScanning, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_label_set_text_static(labelScanning, "SCAN"); + lv_obj_align(labelScanning, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 2); + + buttonToggle = lv_btn_create(lv_scr_act(), nullptr); + buttonToggle->user_data = this; + lv_obj_set_event_cb(buttonToggle, ButtonEventHandler); + lv_obj_set_size(buttonToggle, 236, 40); + lv_obj_set_pos(buttonToggle, 2, 30); + labelToggle = lv_label_create(buttonToggle, nullptr); + + labelEmpty = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelEmpty, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); + lv_label_set_long_mode(labelEmpty, LV_LABEL_LONG_BREAK); + lv_obj_set_width(labelEmpty, 232); + lv_label_set_text_static(labelEmpty, + "No trackers nearby.\n\n" + "Scanning live while\n" + "this is open. Auto\n" + "scan checks every\n" + "5 minutes."); + lv_obj_set_pos(labelEmpty, 4, 84); + + labelPage = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelPage, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonPurple); + + for (uint8_t i = 0; i < perPage; i++) { + const lv_coord_t y = 80 + i * 52; + labelNames[i] = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(labelNames[i], true); + lv_obj_set_pos(labelNames[i], 4, y); + labelDetails[i] = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(labelDetails[i], true); + lv_obj_set_pos(labelDetails[i], 4, y + 22); + } + + UpdateToggle(); + nimble.StartTrackerScan(true); + Refresh(); + taskRefresh = lv_task_create(RefreshTaskCallback, 1000, LV_TASK_PRIO_MID, this); +} + +Trackers::~Trackers() { + lv_task_del(taskRefresh); + nimble.StopTrackerScan(); + lv_obj_clean(lv_scr_act()); +} + +void Trackers::UpdateToggle() { + const bool enabled = nimble.IsTrackerScanEnabled(); + lv_label_set_text_static(labelToggle, enabled ? "AUTO SCAN ON" : "AUTO SCAN OFF"); + lv_obj_set_style_local_text_color(labelToggle, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); + lv_obj_set_style_local_border_color(buttonToggle, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); +} + +void Trackers::OnButtonEvent(lv_obj_t* object, lv_event_t event) { + if (object == buttonToggle && event == LV_EVENT_CLICKED) { + nimble.SetTrackerScanEnabled(!nimble.IsTrackerScanEnabled()); + UpdateToggle(); + } +} + +void Trackers::Refresh() { + // Keep the live scan going while the app is on screen. It stops by itself shortly after the + // screen turns off, since this task doesn't run then. + if (!nimble.IsTrackerScanning() || ++ticks >= liveScanRenewSeconds) { + ticks = 0; + nimble.StartTrackerScan(true); + } + nimble.EvaluateTrackers(); + count = nimble.TrackerSnapshot(trackers); + + // Trackers that are following first, then the strongest signal (closest) first + std::sort(trackers.begin(), trackers.begin() + count, [](const Tracker& a, const Tracker& b) { + if (a.IsFollowing() != b.IsFollowing()) { + return a.IsFollowing(); + } + return a.rssi > b.rssi; + }); + + lv_obj_set_hidden(labelScanning, !nimble.IsTrackerScanning()); + ShowList(); +} + +void Trackers::ShowList() { + const uint8_t pages = std::max((count + perPage - 1) / perPage, 1); + if (page >= pages) { + page = pages - 1; + } + lv_obj_set_hidden(labelEmpty, count != 0); + lv_obj_set_hidden(labelPage, pages < 2); + lv_label_set_text_fmt(labelPage, "%d/%d", page + 1, pages); + lv_obj_align(labelPage, nullptr, LV_ALIGN_IN_BOTTOM_RIGHT, -4, -2); + + for (uint8_t i = 0; i < perPage; i++) { + const size_t index = page * perPage + i; + const bool visible = index < count; + lv_obj_set_hidden(labelNames[i], !visible); + lv_obj_set_hidden(labelDetails[i], !visible); + if (!visible) { + continue; + } + const Tracker& tracker = trackers[index]; + const bool following = tracker.IsFollowing(); + + char bars[6] = " "; + for (uint8_t b = 0; b < Bars(tracker.rssi); b++) { + bars[b] = '|'; + } + lv_label_set_text_fmt(labelNames[i], + "%s %-12s# #fcee0a %4d#", + following ? "#ff2a6d" : "#05d9e8", + Detector::Name(tracker.type), + tracker.rssi); + lv_label_set_text_fmt(labelDetails[i], + "%s %3um %-6s# #fcee0a %s#", + following ? "#ff2a6d" : "#808080", + static_cast(tracker.FollowingSeconds() / 60), + following ? "FOLLOW" : "nearby", + bars); + } +} + +bool Trackers::OnTouchEvent(TouchEvents event) { + const uint8_t pages = std::max((count + perPage - 1) / perPage, 1); + if (event == TouchEvents::SwipeUp && page + 1 < pages) { + page++; + ShowList(); + return true; + } + if (event == TouchEvents::SwipeDown && page > 0) { + page--; + ShowList(); + return true; + } + return false; +} diff --git a/src/displayapp/screens/Trackers.h b/src/displayapp/screens/Trackers.h new file mode 100644 index 0000000000..1f2829f898 --- /dev/null +++ b/src/displayapp/screens/Trackers.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/ble/NimbleController.h" +#include "systemtask/SystemTask.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Trackers : public Screen { + public: + explicit Trackers(Controllers::NimbleController& nimble); + ~Trackers() override; + + void Refresh() override; + bool OnTouchEvent(TouchEvents event) override; + void OnButtonEvent(lv_obj_t* object, lv_event_t event); + + private: + static constexpr uint8_t perPage = 3; + static constexpr uint8_t liveScanRenewSeconds = 10; + + void UpdateToggle(); + void ShowList(); + + Controllers::NimbleController& nimble; + std::array trackers {}; + size_t count = 0; + uint8_t page = 0; + uint8_t ticks = 0; + + lv_obj_t* labelScanning; + lv_obj_t* buttonToggle; + lv_obj_t* labelToggle; + lv_obj_t* labelEmpty; + lv_obj_t* labelPage; + std::array labelNames; + std::array labelDetails; + lv_task_t* taskRefresh; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Trackers; + static constexpr const char* icon = Screens::Symbols::crosshairs; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Trackers(controllers.systemTask->nimble()); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp new file mode 100644 index 0000000000..a0b0a0c6b2 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -0,0 +1,302 @@ +#include "displayapp/screens/WatchFaceNetRunner.h" + +#include +#include "Identity.h" +#include "components/battery/BatteryController.h" +#include "components/ble/BleController.h" +#include "components/ble/NotificationManager.h" +#include "components/heartrate/HeartRateController.h" +#include "components/motion/MotionController.h" +#include "components/settings/Settings.h" +#include "displayapp/screens/WeatherSymbols.h" +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + lv_obj_t* CreateLabel(lv_obj_t* parent, lv_color_t color) { + lv_obj_t* label = lv_label_create(parent, nullptr); + lv_obj_set_style_local_text_color(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, color); + return label; + } + + lv_color_t PowerColor(int percent) { + if (percent > 50) { + return Colors::neonGreen; + } + if (percent > 20) { + return Colors::neonYellow; + } + return Colors::neonMagenta; + } +} + +WatchFaceNetRunner::WatchFaceNetRunner(Controllers::DateTime& dateTimeController, + const Controllers::Battery& batteryController, + const Controllers::Ble& bleController, + Controllers::NotificationManager& notificationManager, + Controllers::Settings& settingsController, + Controllers::HeartRateController& heartRateController, + Controllers::MotionController& motionController, + Controllers::SimpleWeatherService& weatherService) + : currentDateTime {{}}, + dateTimeController {dateTimeController}, + batteryController {batteryController}, + bleController {bleController}, + notificationManager {notificationManager}, + settingsController {settingsController}, + heartRateController {heartRateController}, + motionController {motionController}, + weatherService {weatherService} { + + // HUD frame lines + topLine = lv_line_create(lv_scr_act(), nullptr); + lv_line_set_points(topLine, topLinePoints, 4); + lv_obj_set_style_local_line_color(topLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_line_width(topLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, 2); + + bottomLine = lv_line_create(lv_scr_act(), nullptr); + lv_line_set_points(bottomLine, bottomLinePoints, 4); + lv_obj_set_style_local_line_color(bottomLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_obj_set_style_local_line_width(bottomLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, 2); + + // Status row + labelLink = CreateLabel(lv_scr_act(), Colors::gray); + lv_obj_set_pos(labelLink, 4, 4); + + labelPower = CreateLabel(lv_scr_act(), Colors::neonGreen); + lv_obj_align(labelPower, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 4); + + // Date row + labelDate = CreateLabel(lv_scr_act(), Colors::neonYellow); + lv_obj_set_pos(labelDate, 2, 38); + + labelNotification = CreateLabel(lv_scr_act(), Colors::neonMagenta); + lv_label_set_text_static(labelNotification, "[MSG]"); + lv_obj_align(labelNotification, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 44); + lv_obj_set_hidden(labelNotification, true); + + // Time: a yellow ghost and a magenta shadow sit behind the cyan digits for the + // chromatic-aberration look. The ghost only shows up while glitching. + labelTimeGhost = CreateLabel(lv_scr_act(), Colors::neonYellow); + labelTimeShadow = CreateLabel(lv_scr_act(), Colors::neonMagenta); + labelTime = CreateLabel(lv_scr_act(), Colors::neonCyan); + for (lv_obj_t* label : {labelTimeGhost, labelTimeShadow, labelTime}) { + lv_obj_set_style_local_text_font(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &jetbrains_mono_extrabold_compressed); + lv_obj_set_style_local_text_letter_space(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, -6); + lv_label_set_text_static(label, "00:00"); + } + lv_obj_set_hidden(labelTimeGhost, true); + + glitchSlice = lv_obj_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_bg_color(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_radius(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_border_width(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_hidden(glitchSlice, true); + + // Seconds sweep + barSeconds = lv_bar_create(lv_scr_act(), nullptr); + lv_obj_set_size(barSeconds, 150, 6); + lv_obj_set_pos(barSeconds, 6, 138); + lv_bar_set_range(barSeconds, 0, 59); + lv_obj_set_style_local_bg_color(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, Colors::dimCyan); + lv_obj_set_style_local_bg_opa(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, LV_OPA_COVER); + lv_obj_set_style_local_radius(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_bg_color(barSeconds, LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::neonYellow); + lv_obj_set_style_local_radius(barSeconds, LV_BAR_PART_INDIC, LV_STATE_DEFAULT, 0); + + labelSeconds = CreateLabel(lv_scr_act(), Colors::neonYellow); + lv_obj_set_pos(labelSeconds, 164, 129); + + // Biometrics and environment + labelVitals = CreateLabel(lv_scr_act(), LV_COLOR_WHITE); + lv_label_set_recolor(labelVitals, true); + lv_obj_set_pos(labelVitals, 4, 148); + + labelEnv = CreateLabel(lv_scr_act(), LV_COLOR_WHITE); + lv_label_set_recolor(labelEnv, true); + lv_obj_set_pos(labelEnv, 4, 170); + + // Footer + labelHandle = CreateLabel(lv_scr_act(), Colors::neonMagenta); + lv_label_set_text_static(labelHandle, Pinetime::Identity::handle); + lv_obj_set_pos(labelHandle, 4, 212); + + labelHex = CreateLabel(lv_scr_act(), Colors::neonPurple); + lv_obj_align(labelHex, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 212); + + taskRefresh = lv_task_create(RefreshTaskCallback, LV_DISP_DEF_REFR_PERIOD, LV_TASK_PRIO_MID, this); + Refresh(); +} + +WatchFaceNetRunner::~WatchFaceNetRunner() { + lv_task_del(taskRefresh); + lv_obj_clean(lv_scr_act()); +} + +uint32_t WatchFaceNetRunner::NextRandom() { + // xorshift32 + rngState ^= rngState << 13; + rngState ^= rngState >> 17; + rngState ^= rngState << 5; + return rngState; +} + +void WatchFaceNetRunner::StartGlitch() { + glitchFrames = 4 + NextRandom() % 5; + lv_obj_set_hidden(labelTimeGhost, false); + lv_obj_set_hidden(glitchSlice, false); +} + +void WatchFaceNetRunner::UpdateGlitch() { + if (glitchFrames == 0) { + return; + } + glitchFrames--; + + const lv_coord_t timeX = (LV_HOR_RES - lv_obj_get_width(labelTime)) / 2; + if (glitchFrames == 0) { + lv_obj_set_hidden(labelTimeGhost, true); + lv_obj_set_hidden(glitchSlice, true); + lv_obj_set_pos(labelTime, timeX, timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset, timeY + shadowOffset); + return; + } + + auto jitter = [this](int range) { + return static_cast(static_cast(NextRandom() % (2 * range + 1)) - range); + }; + lv_obj_set_pos(labelTime, timeX + jitter(3), timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset + jitter(8), timeY + shadowOffset); + lv_obj_set_pos(labelTimeGhost, timeX + jitter(10), timeY + jitter(3)); + + lv_obj_set_style_local_bg_color(glitchSlice, + LV_OBJ_PART_MAIN, + LV_STATE_DEFAULT, + (NextRandom() & 1) != 0 ? Colors::neonMagenta : Colors::neonCyan); + lv_obj_set_size(glitchSlice, 40 + NextRandom() % 160, 2 + NextRandom() % 5); + lv_obj_set_pos(glitchSlice, NextRandom() % 120, timeY + NextRandom() % 60); +} + +void WatchFaceNetRunner::Refresh() { + UpdateGlitch(); + + notificationState = notificationManager.AreNewNotificationsAvailable(); + + currentDateTime = std::chrono::time_point_cast(dateTimeController.CurrentDateTime()); + if (currentDateTime.IsUpdated()) { + const uint8_t second = dateTimeController.Seconds(); + lv_bar_set_value(barSeconds, second, LV_ANIM_OFF); + + const auto epoch = std::chrono::duration_cast(dateTimeController.UTCDateTime().time_since_epoch()).count(); + lv_label_set_text_fmt(labelHex, "0x%08lX", static_cast(epoch)); + lv_obj_align(labelHex, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 212); + + // Blink the notification tag once per second + lv_obj_set_hidden(labelNotification, !notificationState.Get() || (second % 2) != 0); + + // Stir the RNG with the clock so every boot glitches differently, then roll for a glitch + rngState ^= static_cast(epoch); + if (rngState == 0) { + rngState = 0x1337c0de; + } + if (glitchFrames == 0 && NextRandom() % 7 == 0) { + StartGlitch(); + } + + currentMinute = std::chrono::time_point_cast(currentDateTime.Get()); + if (currentMinute.IsUpdated()) { + uint8_t hour = dateTimeController.Hours(); + const uint8_t minute = dateTimeController.Minutes(); + + if (settingsController.GetClockType() == Controllers::Settings::ClockType::H12) { + amPm = hour < 12 ? "AM" : "PM"; + if (hour == 0) { + hour = 12; + } else if (hour > 12) { + hour -= 12; + } + } else { + amPm = ""; + } + + for (lv_obj_t* label : {labelTimeGhost, labelTimeShadow, labelTime}) { + lv_label_set_text_fmt(label, "%02d:%02d", hour, minute); + } + const lv_coord_t timeX = (LV_HOR_RES - lv_obj_get_width(labelTime)) / 2; + lv_obj_set_pos(labelTime, timeX, timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset, timeY + shadowOffset); + lv_obj_set_pos(labelTimeGhost, timeX, timeY); + + // A guaranteed glitch at the top of every minute + StartGlitch(); + } + + lv_label_set_text_fmt(labelSeconds, ":%02d%s", second, amPm); + + currentDate = std::chrono::time_point_cast(currentDateTime.Get()); + if (currentDate.IsUpdated()) { + lv_label_set_text_fmt(labelDate, + "%s %04d.%02d.%02d", + dateTimeController.DayOfWeekShortToString(), + dateTimeController.Year(), + static_cast(dateTimeController.Month()), + dateTimeController.Day()); + } + } + + powerPresent = batteryController.IsPowerPresent(); + batteryPercentRemaining = batteryController.PercentRemaining(); + if (batteryPercentRemaining.IsUpdated() || powerPresent.IsUpdated()) { + const int percent = batteryPercentRemaining.Get(); + lv_obj_set_style_local_text_color(labelPower, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, PowerColor(percent)); + if (batteryController.IsCharging()) { + lv_label_set_text_fmt(labelPower, "CHG %d%%", percent); + } else { + lv_label_set_text_fmt(labelPower, "PWR %d%%", percent); + } + lv_obj_align(labelPower, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 4); + } + + bleState = bleController.IsConnected(); + bleRadioEnabled = bleController.IsRadioEnabled(); + if (bleState.IsUpdated() || bleRadioEnabled.IsUpdated()) { + if (!bleRadioEnabled.Get()) { + lv_label_set_text_static(labelLink, "LNK:OFF"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::gray); + } else if (bleState.Get()) { + lv_label_set_text_static(labelLink, "LNK:UP"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + } else { + lv_label_set_text_static(labelLink, "LNK:--"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); + } + } + + stepCount = motionController.NbSteps(); + heartbeat = heartRateController.HeartRate(); + heartbeatRunning = heartRateController.State() != Controllers::HeartRateController::States::Stopped; + if (stepCount.IsUpdated() || heartbeat.IsUpdated() || heartbeatRunning.IsUpdated()) { + if (heartbeatRunning.Get() && heartbeat.Get() != 0) { + lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# %03d #05d9e8 STP# %lu", heartbeat.Get(), stepCount.Get()); + } else { + lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# --- #05d9e8 STP# %lu", stepCount.Get()); + } + } + + currentWeather = weatherService.Current(); + if (currentWeather.IsUpdated()) { + auto optCurrentWeather = currentWeather.Get(); + if (optCurrentWeather) { + int16_t temp = optCurrentWeather->temperature.Celsius(); + char tempUnit = 'C'; + if (settingsController.GetWeatherFormat() == Controllers::Settings::WeatherFormat::Imperial) { + temp = optCurrentWeather->temperature.Fahrenheit(); + tempUnit = 'F'; + } + lv_label_set_text_fmt(labelEnv, "#9d4edd ENV# %d°%c %s", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); + } else { + lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO WEATHER"); + } + } +} diff --git a/src/displayapp/screens/WatchFaceNetRunner.h b/src/displayapp/screens/WatchFaceNetRunner.h new file mode 100644 index 0000000000..05a4520474 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "displayapp/screens/Screen.h" +#include "components/datetime/DateTimeController.h" +#include "components/ble/SimpleWeatherService.h" +#include "utility/DirtyValue.h" + +namespace Pinetime { + namespace Controllers { + class Settings; + class Battery; + class Ble; + class NotificationManager; + class HeartRateController; + class MotionController; + } + + namespace Applications { + namespace Screens { + + class WatchFaceNetRunner : public Screen { + public: + WatchFaceNetRunner(Controllers::DateTime& dateTimeController, + const Controllers::Battery& batteryController, + const Controllers::Ble& bleController, + Controllers::NotificationManager& notificationManager, + Controllers::Settings& settingsController, + Controllers::HeartRateController& heartRateController, + Controllers::MotionController& motionController, + Controllers::SimpleWeatherService& weatherService); + ~WatchFaceNetRunner() override; + + void Refresh() override; + + private: + void UpdateGlitch(); + void StartGlitch(); + uint32_t NextRandom(); + + Utility::DirtyValue batteryPercentRemaining {}; + Utility::DirtyValue powerPresent {}; + Utility::DirtyValue bleState {}; + Utility::DirtyValue bleRadioEnabled {}; + Utility::DirtyValue> currentDateTime {}; + Utility::DirtyValue> currentMinute {}; + Utility::DirtyValue> currentDate {}; + Utility::DirtyValue stepCount {}; + Utility::DirtyValue heartbeat {}; + Utility::DirtyValue heartbeatRunning {}; + Utility::DirtyValue notificationState {}; + Utility::DirtyValue> currentWeather {}; + + static constexpr lv_coord_t timeY = 62; + static constexpr lv_coord_t shadowOffset = 3; + + lv_point_t topLinePoints[4] {{0, 30}, {170, 30}, {180, 38}, {240, 38}}; + lv_point_t bottomLinePoints[4] {{0, 204}, {98, 204}, {108, 196}, {240, 196}}; + + lv_obj_t* topLine; + lv_obj_t* bottomLine; + lv_obj_t* labelLink; + lv_obj_t* labelPower; + lv_obj_t* labelDate; + lv_obj_t* labelNotification; + lv_obj_t* labelTimeGhost; + lv_obj_t* labelTimeShadow; + lv_obj_t* labelTime; + lv_obj_t* barSeconds; + lv_obj_t* labelSeconds; + lv_obj_t* labelVitals; + lv_obj_t* labelEnv; + lv_obj_t* labelHandle; + lv_obj_t* labelHex; + lv_obj_t* glitchSlice; + + uint32_t rngState = 0x1337c0de; + uint8_t glitchFrames = 0; + const char* amPm = ""; + + Controllers::DateTime& dateTimeController; + const Controllers::Battery& batteryController; + const Controllers::Ble& bleController; + Controllers::NotificationManager& notificationManager; + Controllers::Settings& settingsController; + Controllers::HeartRateController& heartRateController; + Controllers::MotionController& motionController; + Controllers::SimpleWeatherService& weatherService; + + lv_task_t* taskRefresh; + }; + } + + template <> + struct WatchFaceTraits { + static constexpr WatchFace watchFace = WatchFace::NetRunner; + static constexpr const char* name = "NetRunner"; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::WatchFaceNetRunner(controllers.dateTimeController, + controllers.batteryController, + controllers.bleController, + controllers.notificationManager, + controllers.settingsController, + controllers.heartRateController, + controllers.motionController, + *controllers.weatherController); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +} diff --git a/src/displayapp/screens/WatchFaceTerminal.cpp b/src/displayapp/screens/WatchFaceTerminal.cpp index 505f79500c..d2c1d99010 100644 --- a/src/displayapp/screens/WatchFaceTerminal.cpp +++ b/src/displayapp/screens/WatchFaceTerminal.cpp @@ -40,8 +40,8 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, notificationIcon = lv_label_create(container, nullptr); labelPrompt1 = lv_label_create(container, nullptr); - lv_obj_set_style_local_text_color(labelPrompt1, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); - lv_label_set_text_static(labelPrompt1, "user@watch:~ $ now"); + lv_obj_set_style_local_text_color(labelPrompt1, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(labelPrompt1, "root@voidsec:~# now"); labelTime = lv_label_create(container, nullptr); lv_label_set_recolor(labelTime, true); @@ -54,7 +54,7 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, stepValue = lv_label_create(container, nullptr); lv_label_set_recolor(stepValue, true); - lv_obj_set_style_local_text_color(stepValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::orange); + lv_obj_set_style_local_text_color(stepValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonYellow); heartbeatValue = lv_label_create(container, nullptr); lv_label_set_recolor(heartbeatValue, true); @@ -66,8 +66,8 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, lv_label_set_recolor(connectState, true); labelPrompt2 = lv_label_create(container, nullptr); - lv_obj_set_style_local_text_color(labelPrompt2, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); - lv_label_set_text_static(labelPrompt2, "user@watch:~ $"); + lv_obj_set_style_local_text_color(labelPrompt2, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(labelPrompt2, "root@voidsec:~#"); lv_obj_align(container, nullptr, LV_ALIGN_IN_TOP_LEFT, 0, 7); @@ -106,9 +106,9 @@ void WatchFaceTerminal::Refresh() { hour = hour - 12; ampmChar[0] = 'P'; } - lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #11cc55 %02d:%02d:%02d %s#", hour, minute, second, ampmChar); + lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #39ff14 %02d:%02d:%02d %s#", hour, minute, second, ampmChar); } else { - lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #11cc55 %02d:%02d:%02d#", hour, minute, second); + lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #39ff14 %02d:%02d:%02d#", hour, minute, second); } currentDate = std::chrono::time_point_cast(currentDateTime.Get()); @@ -116,7 +116,7 @@ void WatchFaceTerminal::Refresh() { uint16_t year = dateTimeController.Year(); Controllers::DateTime::Months month = dateTimeController.Month(); uint8_t day = dateTimeController.Day(); - lv_label_set_text_fmt(labelDate, "#ffffff [DATE]# #007fff %04d-%02d-%02d#", year, month, day); + lv_label_set_text_fmt(labelDate, "#ffffff [DATE]# #05d9e8 %04d-%02d-%02d#", year, month, day); } } @@ -161,12 +161,12 @@ void WatchFaceTerminal::Refresh() { tempUnit = 'F'; } lv_label_set_text_fmt(weather, - "#ffffff [WTHR]# #ffdd00 %d°%c %s#", + "#ffffff [WTHR]# #fcee0a %d°%c %s#", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); } else { - lv_label_set_text(weather, "#ffffff [WTHR]# #ffdd00 ---"); + lv_label_set_text(weather, "#ffffff [WTHR]# #fcee0a ---"); } } @@ -179,7 +179,7 @@ void WatchFaceTerminal::Refresh() { } else { if (bleState.Get()) { lv_label_set_text_static(connectState, "#ffffff [STAT]# Connected"); - lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::blue); + lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); } else { lv_label_set_text_static(connectState, "#ffffff [STAT]# Disconnected"); lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::gray); diff --git a/src/displayapp/screens/settings/SettingHeartRate.cpp b/src/displayapp/screens/settings/SettingHeartRate.cpp index a45dc835ae..31596f0ebf 100644 --- a/src/displayapp/screens/settings/SettingHeartRate.cpp +++ b/src/displayapp/screens/settings/SettingHeartRate.cpp @@ -12,7 +12,9 @@ namespace { } } -SettingHeartRate::SettingHeartRate(Pinetime::Controllers::Settings& settingsController) : settingsController {settingsController} { +SettingHeartRate::SettingHeartRate(Pinetime::Controllers::Settings& settingsController, + Pinetime::Controllers::HeartRateController& heartRateController) + : settingsController {settingsController}, heartRateController {heartRateController} { lv_obj_t* container = lv_cont_create(lv_scr_act(), nullptr); lv_obj_set_style_local_bg_opa(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, LV_OPA_TRANSP); @@ -63,6 +65,11 @@ void SettingHeartRate::UpdateSelected(lv_obj_t* object, lv_event_t event) { if (object == cbOption[i]) { lv_checkbox_set_checked(cbOption[i], true); settingsController.SetHeartRateBackgroundMeasurementInterval(options[i].intervalInSeconds); + // Start measuring right away instead of waiting for the heart rate app to be opened + if (options[i].intervalInSeconds.has_value() && + heartRateController.State() == Pinetime::Controllers::HeartRateController::States::Stopped) { + heartRateController.Enable(); + } } else { lv_checkbox_set_checked(cbOption[i], false); } diff --git a/src/displayapp/screens/settings/SettingHeartRate.h b/src/displayapp/screens/settings/SettingHeartRate.h index 736f2b10d7..dfc10a1fc5 100644 --- a/src/displayapp/screens/settings/SettingHeartRate.h +++ b/src/displayapp/screens/settings/SettingHeartRate.h @@ -6,6 +6,7 @@ #include #include "components/settings/Settings.h" +#include "components/heartrate/HeartRateController.h" #include "displayapp/screens/Screen.h" namespace Pinetime { @@ -14,7 +15,7 @@ namespace Pinetime { namespace Screens { class SettingHeartRate : public Screen { public: - explicit SettingHeartRate(Pinetime::Controllers::Settings& settings); + SettingHeartRate(Pinetime::Controllers::Settings& settings, Pinetime::Controllers::HeartRateController& heartRateController); ~SettingHeartRate() override; void UpdateSelected(lv_obj_t* object, lv_event_t event); @@ -26,6 +27,7 @@ namespace Pinetime { }; Pinetime::Controllers::Settings& settingsController; + Pinetime::Controllers::HeartRateController& heartRateController; static constexpr std::array options = {{ {.intervalInSeconds = std::nullopt, .name = " Off"}, diff --git a/src/heartratetask/HeartRateTask.cpp b/src/heartratetask/HeartRateTask.cpp index fa352772f7..5cbe803202 100644 --- a/src/heartratetask/HeartRateTask.cpp +++ b/src/heartratetask/HeartRateTask.cpp @@ -9,6 +9,9 @@ using namespace Pinetime::Applications; namespace { constexpr TickType_t backgroundMeasurementTimeLimit = 30 * configTICK_RATE_HZ; + // When no pulse is found in the background, retry at most this often. + // Stops continuous mode from running the sensor all day while the watch is off the wrist. + constexpr TickType_t noPulseRetryInterval = 5 * 60 * configTICK_RATE_HZ; } std::optional HeartRateTask::BackgroundMeasurementInterval() const { @@ -16,7 +19,11 @@ std::optional HeartRateTask::BackgroundMeasurementInterval() const { if (!interval.has_value()) { return std::nullopt; } - return interval.value() * configTICK_RATE_HZ; + TickType_t ticks = interval.value() * configTICK_RATE_HZ; + if (noPulse && ticks < noPulseRetryInterval) { + return noPulseRetryInterval; + } + return ticks; } bool HeartRateTask::BackgroundMeasurementNeeded() const { @@ -185,6 +192,7 @@ void HeartRateTask::StartMeasurement() { measurementSucceeded = false; count = 0; measurementStartTime = xTaskGetTickCount(); + lastPulseTime = measurementStartTime; } void HeartRateTask::StopMeasurement() { @@ -235,6 +243,8 @@ void HeartRateTask::HandleSensorData() { } measurementSucceeded = true; valueCurrentlyShown = true; + lastPulseTime = xTaskGetTickCount(); + noPulse = false; controller.Update(Controllers::HeartRateController::States::Running, bpm); return; } @@ -253,6 +263,12 @@ void HeartRateTask::HandleSensorData() { valueCurrentlyShown = false; } if (state == States::BackgroundMeasuring) { + if (xTaskGetTickCount() - lastPulseTime > backgroundMeasurementTimeLimit) { + // No pulse for a while: back off until the watch is worn again + noPulse = true; + controller.Update(Controllers::HeartRateController::States::Running, 0); + valueCurrentlyShown = false; + } lastMeasurementTime = xTaskGetTickCount() - backgroundMeasurementTimeLimit; } else { lastMeasurementTime = xTaskGetTickCount(); diff --git a/src/heartratetask/HeartRateTask.h b/src/heartratetask/HeartRateTask.h index e00bc4d638..399cab7bff 100644 --- a/src/heartratetask/HeartRateTask.h +++ b/src/heartratetask/HeartRateTask.h @@ -51,6 +51,10 @@ namespace Pinetime { Controllers::Ppg ppg; TickType_t lastMeasurementTime; TickType_t measurementStartTime; + // Time of the last valid reading (or the start of the measurement if none yet) + TickType_t lastPulseTime; + // Set when a background measurement found no pulse, e.g. the watch is off the wrist + bool noPulse = false; }; } diff --git a/src/systemtask/Messages.h b/src/systemtask/Messages.h index fee94bb747..bd632e48a8 100644 --- a/src/systemtask/Messages.h +++ b/src/systemtask/Messages.h @@ -29,7 +29,10 @@ namespace Pinetime { BatteryPercentageUpdated, StartFileTransfer, StopFileTransfer, - BleRadioEnableToggle + BleRadioEnableToggle, + OnIntrusion, + OnTrackerAlert, + IntrusionLogChanged }; } } diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 826474a068..4c8e3d3300 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -13,10 +13,13 @@ #include "drivers/SpiNorFlash.h" #include "drivers/TwiMaster.h" #include "drivers/Hrs3300.h" +#include "components/heartrate/HeartRateController.h" #include "drivers/PinMap.h" #include "main.h" #include "BootErrors.h" +#include +#include #include using namespace Pinetime::System; @@ -82,7 +85,8 @@ SystemTask::SystemTask(Drivers::SpiMaster& spi, spiNorFlash, heartRateController, motionController, - fs) { + fs), + sleepTracker {fs} { } void SystemTask::Start() { @@ -138,6 +142,7 @@ void SystemTask::Work() { motionSensor.Init(); motionController.Init(motionSensor.DeviceType()); settingsController.Init(); + sleepTracker.Load(); displayApp.Register(this); displayApp.Register(&nimbleController.weather()); @@ -148,6 +153,9 @@ void SystemTask::Work() { heartRateSensor.Init(); heartRateSensor.Disable(); heartRateApp.Start(); + if (settingsController.GetHeartRateBackgroundMeasurementInterval().has_value()) { + heartRateController.Enable(); + } buttonHandler.Init(this); @@ -239,6 +247,8 @@ void SystemTask::Work() { displayApp.PushMessage(Pinetime::Applications::Display::Messages::NotifyDeviceActivity); isBleDiscoveryTimerRunning = true; bleDiscoveryTimer = 5; + // Give a bonded phone 10 seconds to encrypt the link before calling it an intrusion + intrusionCheckTimer = 100; break; case Messages::BleFirmwareUpdateStarted: GoToRunning(); @@ -337,6 +347,7 @@ void SystemTask::Work() { motionController.AdvanceDay(); break; case Messages::OnNewHour: + sleepTracker.Finalize(); using Pinetime::Controllers::AlarmController; if (settingsController.GetNotificationStatus() != Controllers::Settings::Notification::Sleep && settingsController.GetChimeOption() == Controllers::Settings::ChimesOption::Hours && !alarmController.IsAlerting()) { @@ -366,6 +377,15 @@ void SystemTask::Work() { GoToRunning(); displayApp.PushMessage(Pinetime::Applications::Display::Messages::ShowPairingKey); break; + case Messages::OnIntrusion: + RaiseIntrusionAlert(); + break; + case Messages::OnTrackerAlert: + RaiseTrackerAlert(); + break; + case Messages::IntrusionLogChanged: + SaveIntrusionLog(); + break; case Messages::BleRadioEnableToggle: if (settingsController.GetBleRadioEnabled()) { nimbleController.EnableRadio(); @@ -390,8 +410,42 @@ void SystemTask::Work() { bleDiscoveryTimer--; } } + // Automatic tracker scan every 5 minutes (3000 state update periods) + if (++trackerScanTimer >= 3000) { + trackerScanTimer = 0; + if (nimbleController.IsTrackerScanEnabled()) { + nimbleController.StartTrackerScan(false); + } + } + if (intrusionCheckTimer > 0 && --intrusionCheckTimer == 0) { + if (nimbleController.CheckIntrusion()) { + RaiseIntrusionAlert(); + } else { + SaveIntrusionLog(); + } + } monitor.Process(); NoInit_BackUpTime = dateTimeController.CurrentDateTime(); + { + const auto localMinute = std::chrono::duration_cast(NoInit_BackUpTime.time_since_epoch()).count(); + const bool heartRateRunning = heartRateController.State() == Controllers::HeartRateController::States::Running; + sleepTracker.OnMotion(motionController.X(), + motionController.Y(), + motionController.Z(), + static_cast(localMinute), + batteryController.IsPowerPresent(), + heartRateRunning ? heartRateController.HeartRate() : 0); + } + // The SPI flash is only powered while running, persist pending data then + if (state == SystemTaskState::Running) { + if (sleepTracker.IsDirty()) { + sleepTracker.Save(); + } + SaveIntrusionLog(); + if (nimbleController.TrackerSettingsDirty()) { + nimbleController.SaveTrackerSettings(); + } + } if (nrf_gpio_pin_read(PinMap::Button) == 0) { watchdog.Reload(); } @@ -401,6 +455,56 @@ void SystemTask::Work() { #pragma clang diagnostic pop } +void SystemTask::SaveIntrusionLog() { + auto& log = nimbleController.intrusionLog(); + // The SPI flash is powered down while sleeping, the log gets saved on the next alert or when the app is opened + if (log.IsDirty() && state == SystemTaskState::Running) { + log.Save(); + } +} + +void SystemTask::RaiseIntrusionAlert() { + GoToRunning(); + SaveIntrusionLog(); + + auto& log = nimbleController.intrusionLog(); + if (log.Count() == 0) { + return; + } + const auto entry = log.Get(0); + char address[18]; + Controllers::IntrusionLog::FormatAddress(entry, address); + + Controllers::NotificationManager::Notification notif; + const int size = + snprintf(notif.message.data(), notif.message.size(), "UNKNOWN LINK%c%s\n%s", '\0', address, Controllers::IntrusionLog::Describe(entry)); + notif.size = std::min(size + 1, notif.message.size()); + notif.category = Controllers::NotificationManager::Categories::SimpleAlert; + notificationManager.Push(std::move(notif)); + if (settingsController.GetNotificationStatus() == Pinetime::Controllers::Settings::Notification::On) { + displayApp.PushMessage(Pinetime::Applications::Display::Messages::NewNotification); + } +} + +void SystemTask::RaiseTrackerAlert() { + GoToRunning(); + const auto tracker = nimbleController.LastTrackerAlert(); + + Controllers::NotificationManager::Notification notif; + const int size = snprintf(notif.message.data(), + notif.message.size(), + "TRACKER NEARBY%c%s has been near you for %u min. Open Trackers to find it.", + '\0', + Controllers::TrackerDetector::Name(tracker.type), + static_cast(tracker.FollowingSeconds() / 60)); + notif.size = std::min(size + 1, notif.message.size()); + notif.category = Controllers::NotificationManager::Categories::SimpleAlert; + notificationManager.Push(std::move(notif)); + if (settingsController.GetNotificationStatus() == Pinetime::Controllers::Settings::Notification::On) { + displayApp.PushMessage(Pinetime::Applications::Display::Messages::NewNotification); + } +} + void SystemTask::GoToRunning() { if (state == SystemTaskState::Running) { return; diff --git a/src/systemtask/SystemTask.h b/src/systemtask/SystemTask.h index 606ddd3492..3560604794 100644 --- a/src/systemtask/SystemTask.h +++ b/src/systemtask/SystemTask.h @@ -14,6 +14,7 @@ #include "systemtask/SystemMonitor.h" #include "components/ble/NimbleController.h" +#include "components/sleep/SleepTracker.h" #include "components/ble/NotificationManager.h" #include "components/stopwatch/StopWatchController.h" #include "components/alarm/AlarmController.h" @@ -83,6 +84,10 @@ namespace Pinetime { return wakeLocksHeld > 0; } + Pinetime::Controllers::SleepTracker& sleep() { + return sleepTracker; + } + Pinetime::Controllers::NimbleController& nimble() { return nimbleController; }; @@ -127,11 +132,19 @@ namespace Pinetime { Pinetime::Controllers::TouchHandler& touchHandler; Pinetime::Controllers::ButtonHandler& buttonHandler; Pinetime::Controllers::NimbleController nimbleController; + Pinetime::Controllers::SleepTracker sleepTracker; static void Process(void* instance); void Work(); bool isBleDiscoveryTimerRunning = false; uint8_t bleDiscoveryTimer = 0; + // Counts down in state update periods after a connection, then the connection is classified + uint8_t intrusionCheckTimer = 0; + void RaiseIntrusionAlert(); + void RaiseTrackerAlert(); + // Counts state update periods between automatic tracker scans + uint16_t trackerScanTimer = 0; + void SaveIntrusionLog(); TimerHandle_t measureBatteryTimer; uint8_t wakeLocksHeld = 0; SystemTaskState state = SystemTaskState::Running;