From 1ead799ba7e7014b17111c6924ac6442789e40c0 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:09:52 +0000 Subject: [PATCH 1/8] Cyberpunk edition: NetRunner face, Matrix app, neon theme - Add NetRunner watch face with chromatic-aberration time, random glitch bursts, HUD lines, seconds sweep, vitals and hex epoch - Add Matrix digital rain app, tap to cycle color palettes - Retheme UI with neon cyan/magenta palette and sharp outlined buttons - Recolor Terminal watch face as a root shell - Drop Paint, Paddle, 2048, Dice, Metronome and non-cyberpunk watch faces from the default build - Make NetRunner the default watch face - Document the fork in the README with simulator screenshots --- README.md | 20 ++ doc/cyberpunk/launcher.png | Bin 0 -> 2614 bytes doc/cyberpunk/matrix.png | Bin 0 -> 5545 bytes doc/cyberpunk/netrunner-glitch.png | Bin 0 -> 6004 bytes doc/cyberpunk/netrunner.png | Bin 0 -> 5774 bytes doc/cyberpunk/settings.png | Bin 0 -> 3752 bytes doc/cyberpunk/terminal.png | Bin 0 -> 7630 bytes src/CMakeLists.txt | 3 + src/components/settings/Settings.h | 2 +- src/displayapp/InfiniTimeTheme.cpp | 23 +- src/displayapp/InfiniTimeTheme.h | 20 +- src/displayapp/UserApps.h | 2 + src/displayapp/apps/Apps.h.in | 2 + src/displayapp/apps/CMakeLists.txt | 28 +- src/displayapp/fonts/fonts.json | 2 +- src/displayapp/screens/List.cpp | 7 +- src/displayapp/screens/Matrix.cpp | 142 +++++++++ src/displayapp/screens/Matrix.h | 79 +++++ src/displayapp/screens/Symbols.h | 2 + src/displayapp/screens/SystemInfo.cpp | 2 +- src/displayapp/screens/Tile.cpp | 7 +- src/displayapp/screens/WatchFaceNetRunner.cpp | 301 ++++++++++++++++++ src/displayapp/screens/WatchFaceNetRunner.h | 122 +++++++ src/displayapp/screens/WatchFaceTerminal.cpp | 22 +- 24 files changed, 742 insertions(+), 44 deletions(-) create mode 100644 doc/cyberpunk/launcher.png create mode 100644 doc/cyberpunk/matrix.png create mode 100644 doc/cyberpunk/netrunner-glitch.png create mode 100644 doc/cyberpunk/netrunner.png create mode 100644 doc/cyberpunk/settings.png create mode 100644 doc/cyberpunk/terminal.png create mode 100644 src/displayapp/screens/Matrix.cpp create mode 100644 src/displayapp/screens/Matrix.h create mode 100644 src/displayapp/screens/WatchFaceNetRunner.cpp create mode 100644 src/displayapp/screens/WatchFaceNetRunner.h diff --git a/README.md b/README.md index 68d8779d0e..42fd8e49f0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,26 @@ +## // CYBERPUNK EDITION + +This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTime. + +| NetRunner | Glitch | Matrix | +|:-:|:-:|:-:| +| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | +| **Launcher** | **Settings** | **Terminal** | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.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 is set by `WatchFaceNetRunner::handle`. +- **Matrix app**: digital rain built from ASCII and Cyrillic glyphs. Tap to cycle green, cyan, and magenta palettes. +- **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`. + +> 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/launcher.png b/doc/cyberpunk/launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..627b4a11191895a8ac682a08f6b45e1271b81944 GIT binary patch literal 2614 zcmbuBc`zGjAIIt8O40Te>Zn#6TXD4NDy8ZwR)Z!%T_J)^BZ(taRMG7^O3_zUv?8%- zy)211D|MB+T6LwYqJ%ms;%ZS%-K(9>&b~8!cV_4P zlM@pV5D>SswSw;Yhc8QH&#smk#17II7jqd|JMA3=stty15 z4V^cSi@myLB5jpC=&Mi9_SCI(cY@{j_&8VB*Qag0ZQNz{Zi4mgo!go z##i36DpUgOn5=_xKl%{$Iww9-LY}lb)%9gViO6b(UC&MjdtvJn?p6!_-n;vnGd+aR zuu!su#bu-6sLvB>%yAt%9Cv&r?0d?CZOJjaU@{A{ac3U*1QRh!36&NK=0@7~NDnf=gME`Q-r(sRrWMu!#$8zQSOe9$D@5AUr0-64v_gEut_f1|KxQ$5XS~hO-!(8FSjA)T33nVbp{2!@9qbfqtBxS;)K4+H~VhB z0X)yAG?@;}1(^lbJk~0}c>1`mkV8(*MQ;#gKLaGypn)LBmcux6GWyzxfzJF3E#mrG znDM}4z4SMS3O5~)n1n^V*-&WFv0vzh16h5MKAwZMX;DwA`JJ3uyji3v8J(sAC4(Tv zG;jQ(n9_k%PZ1r8a1isP^KrRbzvtV+;zvV@mZhfCAj=aG(y66Z7+4-7^oH|sz2kZU z7JI+SH~RZ`~TxkBXOYhsXvMkCv8B-S^7r z(e4g0PT}y3Or(d@Dxr~G6lT9Xf+Z7+^Tt!Xa)gWXF&|&B{p#4`@XBt7#6WZ`+ZBR$ za{~^zg7J6Q2o`JFp+j2SUeo^KgLaU57n-bpNos7-oE5?!!$FlXrqDh}aTpbVKwYg^700K_2-3ka#D`B@6|QU}+Y>|WmC zM4GdF{J{9R%i3XQfI8bukM~;TV=l>MZt+uTZB=;fMlf6rh(#2FT3!y_G?kSj_nVvv z0b}tzRenM!M{qJ8;YcRL^QaJpW6yr>l>$w&?zur9Psd;zRC;w;R~LH`o#V>(z3D|_ z0|Rd6v~+%@ahVp#+;*cM=#(&Pk zL#W^WRFUtY8^Yhg#OO){)CV0qxi@zFnH2_-XOd%;N3sI5p2Bp)A~xDdu&pDW_0>;v zy7x{tk?zQyBd}_Q9T7ch*~&>@pa`7FBG+1oSSrg{T;x6@&)EZS34tD~_rhrrIYlP% z)u@cR&lGEA&Ga`?>U0Gk;9rLNz2SYC_}`pQzm;0~>86V=aU0Z;zBtr!G!<9RF{iKT zUW`I{0%%+2W>Fafni%X*M&|RKrMUzmM=_NA!$)_W+A~=vF{^D&)OpEuU$)_;xnKJa z*l^R@cIipe7)b>HN{`b5R}-+)m5sl13j5`+4(3Gc(+%`Y{tWgAevNrKeS2(QEIT5% zK}0^NJ?~WU-R{fC5=(^PqqJn1OAQZg0ah#O%JvcwdL*SaUWF>4$WAt&6Mb?cN8b;CvrDx@am3|&r=${pLJNbH6 zd@TrxzTl|n_ktd*8vmNTejjjO=iczt6~UTEWjq*i$g7!B#kiGBl~S7q1Kjmcbb6c? zEc+VHlbbTEP?AITzBgz~&N z$%i+q&{|I-1{~!0vy{}Q3d%vRoSKj|ue(SIaXuuF}!_AB| RcK?F{cGeD7Obefze*m5&&UpX; literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/matrix.png b/doc/cyberpunk/matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..87fe9c6f823073c0966dbc4b3270f9aa1416ad02 GIT binary patch literal 5545 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 HIlTN2t?IA~ literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/netrunner-glitch.png b/doc/cyberpunk/netrunner-glitch.png new file mode 100644 index 0000000000000000000000000000000000000000..12ac0fcdfda66f095eca66cb00891a7384d039a6 GIT binary patch literal 6004 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 H%MbqraW`7t literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/settings.png b/doc/cyberpunk/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..8b474d1c48247b190bb110cb1e2af77042e5ef61 GIT binary patch literal 3752 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 diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 93196ed6a0..b7c9311729 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -1,20 +1,22 @@ 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::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 +24,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..f1837995c9 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" } ], "bpp": 2, 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/Symbols.h b/src/displayapp/screens/Symbols.h index 058b2d06e9..01c454bbe6 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,6 +42,8 @@ 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 // 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/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp new file mode 100644 index 0000000000..f20c756896 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -0,0 +1,301 @@ +#include "displayapp/screens/WatchFaceNetRunner.h" + +#include +#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, 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()) { + 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 SIGNAL"); + } + } +} diff --git a/src/displayapp/screens/WatchFaceNetRunner.h b/src/displayapp/screens/WatchFaceNetRunner.h new file mode 100644 index 0000000000..3829710c42 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.h @@ -0,0 +1,122 @@ +#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: + // Handle shown in the bottom-left corner of the HUD. Change it to your own. + static constexpr const char* handle = "VOIDSEC"; + + 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); From ca89070269a59d3d3886a1cfb8fe70f5e5ef6bbd Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:23:55 +0000 Subject: [PATCH 2/8] Add Badge app, VOIDSEC Bluetooth name and auto heart rate - Badge app shows the handle and a QR code linking to GitHub, keeps the screen on while open. QR image is generated by tools/badge/generate_badge_qr.py - Move the handle to src/Identity.h and use it as the BLE device name - Start heart rate measurement at boot and default the background interval to 5 minutes - Back off background measurement to every 5 minutes when no pulse is found, so continuous mode doesn't run the sensor while off the wrist - Enable heart rate measurement when a background interval is picked in settings - NetRunner shows --- instead of 000 when there is no pulse --- README.md | 15 +- doc/cyberpunk/badge.png | Bin 0 -> 3122 bytes doc/cyberpunk/launcher.png | Bin 2614 -> 3230 bytes src/CMakeLists.txt | 4 + src/Identity.h | 9 + src/components/ble/NimbleController.h | 3 +- src/components/settings/Settings.h | 2 +- src/displayapp/DisplayApp.cpp | 2 +- src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/screens/Badge.cpp | 41 ++++ src/displayapp/screens/Badge.h | 38 ++++ src/displayapp/screens/BadgeQr.h | 191 ++++++++++++++++++ src/displayapp/screens/WatchFaceNetRunner.cpp | 5 +- src/displayapp/screens/WatchFaceNetRunner.h | 3 - .../screens/settings/SettingHeartRate.cpp | 9 +- .../screens/settings/SettingHeartRate.h | 4 +- src/heartratetask/HeartRateTask.cpp | 18 +- src/heartratetask/HeartRateTask.h | 4 + src/systemtask/SystemTask.cpp | 4 + 21 files changed, 338 insertions(+), 17 deletions(-) create mode 100644 doc/cyberpunk/badge.png create mode 100644 src/Identity.h create mode 100644 src/displayapp/screens/Badge.cpp create mode 100644 src/displayapp/screens/Badge.h create mode 100644 src/displayapp/screens/BadgeQr.h diff --git a/README.md b/README.md index 42fd8e49f0..b4ae123368 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,19 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTime. -| NetRunner | Glitch | Matrix | -|:-:|:-:|:-:| -| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | -| **Launcher** | **Settings** | **Terminal** | -| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | +| 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** | | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.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 is set by `WatchFaceNetRunner::handle`. +- **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`). +- **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`. diff --git a/doc/cyberpunk/badge.png b/doc/cyberpunk/badge.png new file mode 100644 index 0000000000000000000000000000000000000000..4949b3e139d9879ebd98a36d8872b5fdb4a52bbe GIT binary patch literal 3122 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 RcK?F{cGeD7Obefze*m5&&UpX; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6cf0447b06..6af48ec5e5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -394,6 +394,7 @@ list(APPEND SOURCE_FILES displayapp/screens/Timer.cpp displayapp/screens/Dice.cpp displayapp/screens/Matrix.cpp + displayapp/screens/Badge.cpp displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp @@ -593,6 +594,7 @@ set(INCLUDE_FILES ${CMAKE_CURRENT_BINARY_DIR}/src/Version.h BootloaderVersion.h + Identity.h logging/Logger.h logging/NrfLogger.h displayapp/DisplayApp.h @@ -621,6 +623,8 @@ set(INCLUDE_FILES displayapp/screens/Timer.h displayapp/screens/Dice.h displayapp/screens/Matrix.h + displayapp/screens/Badge.h + displayapp/screens/BadgeQr.h displayapp/screens/Alarm.h displayapp/Colors.h displayapp/widgets/Counter.h 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/NimbleController.h b/src/components/ble/NimbleController.h index 597ef0cc34..77e0c68b04 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 @@ -85,7 +86,7 @@ namespace Pinetime { void PersistBond(struct ble_gap_conn_desc& desc); void RestoreBond(); - static constexpr const char* deviceName = "InfiniTime"; + static constexpr const char* deviceName = Pinetime::Identity::handle; Pinetime::System::SystemTask& systemTask; Ble& bleController; DateTime& dateTimeController; diff --git a/src/components/settings/Settings.h b/src/components/settings/Settings.h index 96f867a272..2e7fcd9a55 100644 --- a/src/components/settings/Settings.h +++ b/src/components/settings/Settings.h @@ -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/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/UserApps.h b/src/displayapp/UserApps.h index 94c6f289f1..19fdc0e6d0 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -5,6 +5,7 @@ #include "displayapp/screens/Alarm.h" #include "displayapp/screens/Dice.h" #include "displayapp/screens/Matrix.h" +#include "displayapp/screens/Badge.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 3d95fea18b..2731bda3e3 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -31,6 +31,7 @@ namespace Pinetime { Dice, Weather, Matrix, + Badge, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index b7c9311729..4228b55465 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -2,6 +2,7 @@ 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::Matrix") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Badge") 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") 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/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp index f20c756896..160daf1876 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.cpp +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -1,6 +1,7 @@ #include "displayapp/screens/WatchFaceNetRunner.h" #include +#include "Identity.h" #include "components/battery/BatteryController.h" #include "components/ble/BleController.h" #include "components/ble/NotificationManager.h" @@ -118,7 +119,7 @@ WatchFaceNetRunner::WatchFaceNetRunner(Controllers::DateTime& dateTimeController // Footer labelHandle = CreateLabel(lv_scr_act(), Colors::neonMagenta); - lv_label_set_text_static(labelHandle, handle); + lv_label_set_text_static(labelHandle, Pinetime::Identity::handle); lv_obj_set_pos(labelHandle, 4, 212); labelHex = CreateLabel(lv_scr_act(), Colors::neonPurple); @@ -276,7 +277,7 @@ void WatchFaceNetRunner::Refresh() { heartbeat = heartRateController.HeartRate(); heartbeatRunning = heartRateController.State() != Controllers::HeartRateController::States::Stopped; if (stepCount.IsUpdated() || heartbeat.IsUpdated() || heartbeatRunning.IsUpdated()) { - if (heartbeatRunning.Get()) { + 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()); diff --git a/src/displayapp/screens/WatchFaceNetRunner.h b/src/displayapp/screens/WatchFaceNetRunner.h index 3829710c42..05a4520474 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.h +++ b/src/displayapp/screens/WatchFaceNetRunner.h @@ -25,9 +25,6 @@ namespace Pinetime { class WatchFaceNetRunner : public Screen { public: - // Handle shown in the bottom-left corner of the HUD. Change it to your own. - static constexpr const char* handle = "VOIDSEC"; - WatchFaceNetRunner(Controllers::DateTime& dateTimeController, const Controllers::Battery& batteryController, const Controllers::Ble& bleController, 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/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 826474a068..c60f4cb0e6 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -13,6 +13,7 @@ #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" @@ -148,6 +149,9 @@ void SystemTask::Work() { heartRateSensor.Init(); heartRateSensor.Disable(); heartRateApp.Start(); + if (settingsController.GetHeartRateBackgroundMeasurementInterval().has_value()) { + heartRateController.Enable(); + } buttonHandler.Init(this); From a866b7ff303b4eb503c158e9ad1339500b88caa0 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:44:43 +0000 Subject: [PATCH 3/8] Add Bluetooth intrusion log with alerts - Log every connection that doesn't encrypt with the bonded phone: time, address, duration, pairing attempts and failures - Raise an UNKNOWN LINK notification with vibration when a bond exists or pairing fails, 10 seconds after connecting or on disconnect - Persist the log to /intrusion.dat, keep the last 16 entries - Add Intrusion Log app with alert toggle and wipe button - Build CI artifacts for voidsec-hub branches - Document Gadgetbridge install steps --- .github/workflows/main.yml | 2 +- README.md | 12 +- doc/cyberpunk/intrusions.png | Bin 0 -> 6654 bytes src/CMakeLists.txt | 5 + src/components/ble/IntrusionLog.cpp | 202 ++++++++++++++++++++++++ src/components/ble/IntrusionLog.h | 110 +++++++++++++ src/components/ble/NimbleController.cpp | 35 +++- src/components/ble/NimbleController.h | 10 ++ src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/screens/IntrusionLog.cpp | 164 +++++++++++++++++++ src/displayapp/screens/IntrusionLog.h | 58 +++++++ src/systemtask/Messages.h | 4 +- src/systemtask/SystemTask.cpp | 48 ++++++ src/systemtask/SystemTask.h | 4 + 16 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 doc/cyberpunk/intrusions.png create mode 100644 src/components/ble/IntrusionLog.cpp create mode 100644 src/components/ble/IntrusionLog.h create mode 100644 src/displayapp/screens/IntrusionLog.cpp create mode 100644 src/displayapp/screens/IntrusionLog.h 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 b4ae123368..ca6f219892 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,28 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTim | 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** | | -| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.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) | 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. - **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, enable firmware updates in *Settings → Firmware update* if they are turned off. +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? diff --git a/doc/cyberpunk/intrusions.png b/doc/cyberpunk/intrusions.png new file mode 100644 index 0000000000000000000000000000000000000000..e47919dc58754f8a0107799156597519a4cb42e1 GIT binary patch literal 6654 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 literal 0 HcmV?d00001 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6af48ec5e5..2ee7eb5253 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -395,6 +395,7 @@ list(APPEND SOURCE_FILES displayapp/screens/Dice.cpp displayapp/screens/Matrix.cpp displayapp/screens/Badge.cpp + displayapp/screens/IntrusionLog.cpp displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp @@ -466,6 +467,7 @@ list(APPEND SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp components/ble/ServiceDiscovery.cpp components/ble/HeartRateService.cpp components/ble/MotionService.cpp @@ -536,6 +538,7 @@ list(APPEND RECOVERY_SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp components/ble/ServiceDiscovery.cpp components/ble/NavigationService.cpp components/ble/HeartRateService.cpp @@ -625,6 +628,7 @@ set(INCLUDE_FILES displayapp/screens/Matrix.h displayapp/screens/Badge.h displayapp/screens/BadgeQr.h + displayapp/screens/IntrusionLog.h displayapp/screens/Alarm.h displayapp/Colors.h displayapp/widgets/Counter.h @@ -660,6 +664,7 @@ set(INCLUDE_FILES components/ble/BatteryInformationService.h components/ble/FSService.h components/ble/ImmediateAlertService.h + components/ble/IntrusionLog.h components/ble/ServiceDiscovery.h components/ble/BleClient.h components/ble/HeartRateService.h 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..447849fb7f 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,7 @@ void NimbleController::Init() { ASSERT(rc == 0); RestoreBond(); + intrusionLogger.Load(); StartAdvertising(); } @@ -207,6 +209,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 +234,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 +277,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 +303,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; @@ -480,6 +503,16 @@ void NimbleController::PersistBond(struct ble_gap_conn_desc& desc) { } } +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 77e0c68b04..39e73bf9c3 100644 --- a/src/components/ble/NimbleController.h +++ b/src/components/ble/NimbleController.h @@ -18,6 +18,7 @@ #include "components/ble/FSService.h" #include "components/ble/HeartRateService.h" #include "components/ble/ImmediateAlertService.h" +#include "components/ble/IntrusionLog.h" #include "components/ble/MusicService.h" #include "components/ble/NavigationService.h" #include "components/ble/ServiceDiscovery.h" @@ -72,6 +73,13 @@ 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(); + uint16_t connHandle(); void NotifyBatteryLevel(uint8_t level); @@ -85,6 +93,7 @@ namespace Pinetime { private: void PersistBond(struct ble_gap_conn_desc& desc); void RestoreBond(); + bool HasBond(); static constexpr const char* deviceName = Pinetime::Identity::handle; Pinetime::System::SystemTask& systemTask; @@ -108,6 +117,7 @@ namespace Pinetime { MotionService motionService; FSService fsService; ServiceDiscovery serviceDiscovery; + IntrusionLog intrusionLogger; uint8_t addrType; uint16_t connectionHandle = BLE_HS_CONN_HANDLE_NONE; diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 19fdc0e6d0..0b3240aebb 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -6,6 +6,7 @@ #include "displayapp/screens/Dice.h" #include "displayapp/screens/Matrix.h" #include "displayapp/screens/Badge.h" +#include "displayapp/screens/IntrusionLog.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 2731bda3e3..44431f95bd 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -32,6 +32,7 @@ namespace Pinetime { Weather, Matrix, Badge, + IntrusionLog, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 4228b55465..8d6fbdeaf9 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -3,6 +3,7 @@ if(DEFINED ENABLE_USERAPPS) else () 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::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") 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/systemtask/Messages.h b/src/systemtask/Messages.h index fee94bb747..2bf7d8df65 100644 --- a/src/systemtask/Messages.h +++ b/src/systemtask/Messages.h @@ -29,7 +29,9 @@ namespace Pinetime { BatteryPercentageUpdated, StartFileTransfer, StopFileTransfer, - BleRadioEnableToggle + BleRadioEnableToggle, + OnIntrusion, + IntrusionLogChanged }; } } diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index c60f4cb0e6..2c433c7e2b 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -18,6 +18,8 @@ #include "main.h" #include "BootErrors.h" +#include +#include #include using namespace Pinetime::System; @@ -243,6 +245,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(); @@ -370,6 +374,12 @@ void SystemTask::Work() { GoToRunning(); displayApp.PushMessage(Pinetime::Applications::Display::Messages::ShowPairingKey); break; + case Messages::OnIntrusion: + RaiseIntrusionAlert(); + break; + case Messages::IntrusionLogChanged: + SaveIntrusionLog(); + break; case Messages::BleRadioEnableToggle: if (settingsController.GetBleRadioEnabled()) { nimbleController.EnableRadio(); @@ -394,6 +404,13 @@ void SystemTask::Work() { bleDiscoveryTimer--; } } + if (intrusionCheckTimer > 0 && --intrusionCheckTimer == 0) { + if (nimbleController.CheckIntrusion()) { + RaiseIntrusionAlert(); + } else { + SaveIntrusionLog(); + } + } monitor.Process(); NoInit_BackUpTime = dateTimeController.CurrentDateTime(); if (nrf_gpio_pin_read(PinMap::Button) == 0) { @@ -405,6 +422,37 @@ 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::GoToRunning() { if (state == SystemTaskState::Running) { return; diff --git a/src/systemtask/SystemTask.h b/src/systemtask/SystemTask.h index 606ddd3492..a083182df2 100644 --- a/src/systemtask/SystemTask.h +++ b/src/systemtask/SystemTask.h @@ -132,6 +132,10 @@ namespace Pinetime { 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 SaveIntrusionLog(); TimerHandle_t measureBatteryTimer; uint8_t wakeLocksHeld = 0; SystemTaskState state = SystemTaskState::Running; From 03fce1e9aeb8a7039cc7acc9041cc18dbe7366bb Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:45:00 +0000 Subject: [PATCH 4/8] Fix menu names in Gadgetbridge install steps --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca6f219892..881c109c16 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ What's different from upstream: ### 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, enable firmware updates in *Settings → Firmware update* if they are turned off. +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. From 19f68450591440bcaf6d0711a247d979941a3cd5 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 22:14:28 +0000 Subject: [PATCH 5/8] Fix calculator equals and clarify missing weather - Show the answer on the main line after pressing =, clear the operator so the next digit starts a new number and the next operator continues from the answer - Ignore presses that land between buttons, which replayed the previous key because LVGL keeps it as the active button - Watch face shows ENV NO WEATHER instead of NO SIGNAL when the phone hasn't sent weather in the last 24 hours --- src/displayapp/screens/Calculator.cpp | 20 +++++++++++++------ src/displayapp/screens/WatchFaceNetRunner.cpp | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) 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/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp index 160daf1876..a0b0a0c6b2 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.cpp +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -296,7 +296,7 @@ void WatchFaceNetRunner::Refresh() { } 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 SIGNAL"); + lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO WEATHER"); } } } From 8e4cc3ff187d99071bc441f1a73c47f4484b66a5 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Sun, 27 Sep 2026 03:26:08 +0000 Subject: [PATCH 6/8] Add automatic sleep tracking - Record wrist movement per minute for the last 24 hours from the accelerometer samples the system task already reads - Score minutes asleep from sustained movement around them, find the night as the longest sleep block allowing wake-ups up to an hour - Treat charging and 2.5 hours of perfect stillness as off the wrist - Estimate still/light/awake stages and keep average and lowest heart rate per night - Store the last 7 nights in /sleep.dat, finalized hourly and saved while the screen is on - Add Sleep app with hypnogram, stats and history --- README.md | 3 + doc/cyberpunk/sleep.png | Bin 0 -> 6745 bytes src/CMakeLists.txt | 8 + src/components/sleep/SleepAnalysis.cpp | 156 ++++++++++++++++ src/components/sleep/SleepAnalysis.h | 87 +++++++++ src/components/sleep/SleepTracker.cpp | 245 +++++++++++++++++++++++++ src/components/sleep/SleepTracker.h | 109 +++++++++++ src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/fonts/fonts.json | 2 +- src/displayapp/screens/Sleep.cpp | 219 ++++++++++++++++++++++ src/displayapp/screens/Sleep.h | 69 +++++++ src/systemtask/SystemTask.cpp | 22 ++- src/systemtask/SystemTask.h | 6 + 15 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 doc/cyberpunk/sleep.png create mode 100644 src/components/sleep/SleepAnalysis.cpp create mode 100644 src/components/sleep/SleepAnalysis.h create mode 100644 src/components/sleep/SleepTracker.cpp create mode 100644 src/components/sleep/SleepTracker.h create mode 100644 src/displayapp/screens/Sleep.cpp create mode 100644 src/displayapp/screens/Sleep.h diff --git a/README.md b/README.md index 881c109c16..72b075e03e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTim | ![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** | | | | +| ![Sleep](doc/cyberpunk/sleep.png) | | | | What's different from upstream: @@ -36,6 +38,7 @@ What's different from upstream: - **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 from wrist movement, no button to press. Shows time asleep, when you fell asleep and woke up, a hypnogram (awake / light / still), average and lowest heart rate and sleep efficiency, for tonight (marked LIVE while you're asleep) and the last 7 nights. It uses the accelerometer that is already running for step counting, so it costs no extra battery. Stages come from motion only: "still" means no movement at all, it's not the same as EEG deep sleep. Charging, or lying perfectly still for 2.5 hours, counts as off the wrist. Naps under an hour are ignored. The code lives in `src/components/sleep/`; the tuning constants are at the top of `SleepAnalysis.h` and `SleepTracker.h`. - **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. diff --git a/doc/cyberpunk/sleep.png b/doc/cyberpunk/sleep.png new file mode 100644 index 0000000000000000000000000000000000000000..e08e24f6fad7158c9b151709416857665ec0ff77 GIT binary patch literal 6745 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 - WindowBefore; + const int32_t to = static_cast(index) + WindowAfter; + for (int32_t i = from; i <= to; i++) { + if (i < 0 || i >= window.Size()) { + continue; + } + const uint8_t level = window[i]; + if (!IsValid(level) || level > ActiveLevel) { + active++; + } + } + return active <= MaxActiveInWindow; + } + + // Count of consecutive minutes with the same asleep value starting at index, going forward or backward + uint16_t RunLength(const uint8_t* bits, uint16_t size, uint16_t index, bool value, int8_t direction) { + uint16_t length = 0; + int32_t i = index; + while (i >= 0 && i < size && GetBit(bits, i) == value) { + length++; + i += direction; + } + return length; + } +} + +Stage Pinetime::Controllers::Sleep::StageAt(const Window& window, const uint8_t* asleepBits, uint16_t index) { + if (!IsValid(window[index])) { + return Stage::None; + } + if (!GetBit(asleepBits, index)) { + return Stage::Awake; + } + const int32_t from = static_cast(index) - StillRadius; + const int32_t to = static_cast(index) + StillRadius; + for (int32_t i = from; i <= to; i++) { + if (i < 0 || i >= window.Size()) { + continue; + } + if (window[i] != 0) { + return Stage::Light; + } + } + return Stage::Still; +} + +Session Pinetime::Controllers::Sleep::Analyze(const Window& window, uint8_t* asleepBits) { + const uint16_t size = window.Size(); + + for (uint16_t i = 0; i < size; i++) { + SetBit(asleepBits, i, ScoreAsleep(window, i)); + } + + // Rescore brief arousals (1-2 minutes) inside solid sleep as sleep, a turn-over shouldn't count as waking up + for (uint16_t i = 0; i < size; i++) { + if (GetBit(asleepBits, i) || !IsValid(window[i])) { + continue; + } + const uint16_t awakeRun = RunLength(asleepBits, size, i, false, 1); + const uint16_t end = i + awakeRun; + if (awakeRun <= 2 && i > 0 && end < size && RunLength(asleepBits, size, i - 1, true, -1) >= OnsetMinutes && + RunLength(asleepBits, size, end, true, 1) >= OnsetMinutes) { + bool allValid = true; + for (uint16_t j = i; j < end; j++) { + allValid = allValid && IsValid(window[j]); + } + if (allValid) { + for (uint16_t j = i; j < end; j++) { + SetBit(asleepBits, j, true); + } + } + } + i = end; + } + + Session best; + bool inBlock = false; + uint16_t blockStart = 0; + uint16_t lastAsleep = 0; + uint16_t asleepCount = 0; + uint16_t run = 0; + + auto closeBlock = [&](bool atEnd) { + // Asleep at least half of the time in bed, otherwise it's restless noise rather than a night + const bool efficient = asleepCount * 2 >= (lastAsleep - blockStart + 1); + if (inBlock && efficient && asleepCount >= MinAsleepMinutes && asleepCount >= best.asleep) { + best.found = true; + best.start = blockStart; + best.end = lastAsleep; + best.asleep = asleepCount; + best.ongoing = atEnd && (size - 1 - lastAsleep) <= OngoingMinutes; + } + inBlock = false; + asleepCount = 0; + }; + + for (uint16_t i = 0; i < size; i++) { + const bool valid = IsValid(window[i]); + const bool asleep = valid && GetBit(asleepBits, i); + run = asleep ? run + 1 : 0; + + if (inBlock) { + // Off-wrist minutes don't end the session by themselves (e.g. a short charge), they count as unknown + if ((i - lastAsleep) > MaxWakeGapMinutes) { + closeBlock(false); + } else if (asleep) { + lastAsleep = i; + asleepCount++; + } + } + if (!inBlock && run >= OnsetMinutes) { + inBlock = true; + blockStart = i + 1 - OnsetMinutes; + lastAsleep = i; + asleepCount = OnsetMinutes; + } + } + closeBlock(true); + + if (best.found) { + best.asleep = 0; + for (uint16_t i = best.start; i <= best.end; i++) { + switch (StageAt(window, asleepBits, i)) { + case Stage::Still: + best.still++; + best.asleep++; + break; + case Stage::Light: + best.light++; + best.asleep++; + break; + case Stage::Awake: + best.awake++; + break; + case Stage::None: + break; + } + } + } + return best; +} diff --git a/src/components/sleep/SleepAnalysis.h b/src/components/sleep/SleepAnalysis.h new file mode 100644 index 0000000000..eac9678028 --- /dev/null +++ b/src/components/sleep/SleepAnalysis.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include + +// Motion based sleep scoring (actigraphy). Pure functions without FreeRTOS dependencies so they can be tested on a PC. +// +// Input is one activity level per minute: 0 means no movement above the sensor noise floor, +// higher values mean more movement. A minute is scored asleep when a weighted window of the +// surrounding minutes has little sustained movement (in the spirit of the Cole-Kripke algorithm): +// a turn-over is one or two active minutes, being awake is movement minute after minute. +// The longest block of sleep, allowing short wake-ups, is reported as the session. +// Stages are estimated from motion only: "still" means asleep without any movement for +// several minutes around it, "light" is asleep with small movements. +namespace Pinetime { + namespace Controllers { + namespace Sleep { + static constexpr uint8_t LevelMax = 253; + static constexpr uint8_t LevelOffWrist = 254; + static constexpr uint8_t LevelNoData = 255; + + // Tuning + static constexpr uint8_t ActiveLevel = 3; // A minute above this level counts as active + static constexpr uint8_t WindowBefore = 4; // Scoring window: minutes before... + static constexpr uint8_t WindowAfter = 2; // ...and after the scored minute + static constexpr uint8_t MaxActiveInWindow = 2; // More active minutes than this in the window means awake + static constexpr uint8_t OnsetMinutes = 10; // Consecutive asleep minutes needed to start a session + static constexpr uint8_t MaxWakeGapMinutes = 60; // A longer awake or off-wrist stretch ends the session + static constexpr uint16_t MinAsleepMinutes = 60; // Shorter sessions are ignored + static constexpr uint8_t OngoingMinutes = 15; // Session ending this close to now is still in progress + static constexpr uint8_t StillRadius = 5; // Minutes on each side that must be motionless for "still" + + enum class Stage : uint8_t { None = 0, Awake = 1, Light = 2, Still = 3 }; + + // Chronological view over a ring buffer: index 0 is the oldest minute, Size() - 1 the newest + class Window { + public: + Window(const uint8_t* ring, uint16_t capacity, uint16_t newest, uint16_t count) + : ring {ring}, capacity {capacity}, newest {newest}, count {count} { + } + + uint16_t Size() const { + return count; + } + + uint8_t operator[](uint16_t index) const { + return ring[(newest + capacity + 1 - count + index) % capacity]; + } + + private: + const uint8_t* ring; + uint16_t capacity; + uint16_t newest; + uint16_t count; + }; + + struct Session { + bool found = false; + bool ongoing = false; + uint16_t start = 0; // Window index of the first asleep minute + uint16_t end = 0; // Window index of the last asleep minute + uint16_t asleep = 0; + uint16_t still = 0; + uint16_t light = 0; + uint16_t awake = 0; + }; + + // Scores every minute of the window into asleepBits (at least Size() / 8 + 1 bytes) and finds the main session + Session Analyze(const Window& window, uint8_t* asleepBits); + + // Stage of a minute, only valid after Analyze() filled asleepBits + Stage StageAt(const Window& window, const uint8_t* asleepBits, uint16_t index); + + 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)); + } + } + } + } +} diff --git a/src/components/sleep/SleepTracker.cpp b/src/components/sleep/SleepTracker.cpp new file mode 100644 index 0000000000..63ff9f24c3 --- /dev/null +++ b/src/components/sleep/SleepTracker.cpp @@ -0,0 +1,245 @@ +#include "components/sleep/SleepTracker.h" + +#include +#include +#include "components/fs/FS.h" + +using namespace Pinetime::Controllers; + +namespace { + constexpr uint32_t sameNightSeconds = 3 * 60 * 60; + + 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); + levels.fill(Sleep::LevelNoData); +} + +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); + movement = 0; + minuteOffWrist = false; + minuteHeartRate = 0; + } + + if (havePrevious) { + const int32_t change = std::abs(x - previousX) + std::abs(y - previousY) + std::abs(z - previousZ); + if (change > NoiseFloor) { + movement += change - NoiseFloor; + } + } + havePrevious = true; + previousX = x; + previousY = y; + previousZ = z; + + minuteOffWrist = minuteOffWrist || offWrist; + if (heartRate != 0) { + minuteHeartRate = heartRate; + } +} + +void SleepTracker::CloseMinute() { + uint8_t level = static_cast(std::min(movement / LevelDivider, Sleep::LevelMax)); + if (minuteOffWrist) { + level = Sleep::LevelOffWrist; + } + + // Nobody lies perfectly still for hours: the watch is on the nightstand + stillMinutes = (level == 0) ? stillMinutes + 1 : 0; + if (stillMinutes >= OffWristStillMinutes) { + if (stillMinutes == OffWristStillMinutes) { + for (uint16_t i = 0; i < OffWristStillMinutes - 1 && i < count; i++) { + levels[(newest + Minutes - i) % Minutes] = Sleep::LevelOffWrist; + } + } + level = Sleep::LevelOffWrist; + } + + newest = (newest + 1) % Minutes; + levels[newest] = level; + count = std::min(count + 1, Minutes); + + 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) + newest = (newest + 1) % Minutes; + levels[newest] = Sleep::LevelNoData; + count = std::min(count + 1, Minutes); + stillMinutes = 0; + } + } + currentMinute = minute; +} + +bool SleepTracker::Build(const Sleep::Session& session, Night& night) { + const Sleep::Window window(levels.data(), Minutes, newest, count); + // 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.still = session.still; + night.awake = session.awake; + 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 still = 0; + uint8_t light = 0; + for (uint8_t m = 0; m < EpochMinutes; m++) { + const uint16_t index = session.start + epoch * EpochMinutes + m; + if (index > session.end) { + break; + } + switch (Sleep::StageAt(window, asleepBits.data(), index)) { + case Sleep::Stage::Awake: + awake++; + break; + case Sleep::Stage::Still: + still++; + break; + case Sleep::Stage::Light: + light++; + break; + case Sleep::Stage::None: + break; + } + } + Sleep::Stage stage = Sleep::Stage::None; + if (awake >= 2) { + stage = Sleep::Stage::Awake; + } else if (still >= 3) { + stage = Sleep::Stage::Still; + } else if (still + light > 0) { + stage = Sleep::Stage::Light; + } + 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; + } + return true; +} + +bool SleepTracker::Live(Night& night) { + xSemaphoreTake(mutex, portMAX_DELAY); + const Sleep::Window window(levels.data(), Minutes, newest, count); + const Sleep::Session session = Sleep::Analyze(window, asleepBits.data()); + const bool found = session.found && Build(session, night); + xSemaphoreGive(mutex); + return 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..f1d37a0f3c --- /dev/null +++ b/src/components/sleep/SleepTracker.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "components/sleep/SleepAnalysis.h" + +namespace Pinetime { + namespace Controllers { + class FS; + + // Records wrist movement per minute for the last 24 hours, detects sleep from it + // 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 from falling asleep to waking up + uint16_t asleep; // Minutes + uint16_t still; + uint16_t awake; + uint8_t avgHeartRate; // 0 when unknown + uint8_t minHeartRate; + uint8_t epochs; + uint8_t ongoing; + 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 int16_t NoiseFloor = 24; // Sample-to-sample change (mg, summed over axes) ignored as noise + static constexpr uint8_t LevelDivider = 16; // Movement sum per minute divided by this is the level + static constexpr uint16_t OffWristStillMinutes = 150; // No movement at all for this long: watch is off the wrist + static constexpr uint32_t magic = 0x50454c53; // "SLEP" + static constexpr uint8_t fileVersion = 1; + static constexpr const char* fileName = "/sleep.dat"; + + void CloseMinute(); + void AdvanceTo(uint32_t minute); + bool Build(const Sleep::Session& session, Night& night); + + FS& fs; + SemaphoreHandle_t mutex; + + std::array levels; + std::array asleepBits {}; + uint16_t newest = Minutes - 1; + uint16_t count = 0; + + struct HeartRateSlot { + uint16_t sum; + uint8_t samples; + }; + + std::array heartRates {}; + + uint32_t currentMinute = 0; + bool started = false; + uint32_t movement = 0; + bool minuteOffWrist = false; + uint8_t minuteHeartRate = 0; + uint16_t stillMinutes = 0; + bool havePrevious = false; + int16_t previousX = 0; + int16_t previousY = 0; + int16_t previousZ = 0; + + std::array history {}; + uint8_t historyCount = 0; + bool dirty = false; + }; + } +} diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 0b3240aebb..8034d9fe9f 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -7,6 +7,7 @@ #include "displayapp/screens/Matrix.h" #include "displayapp/screens/Badge.h" #include "displayapp/screens/IntrusionLog.h" +#include "displayapp/screens/Sleep.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 44431f95bd..3f28b96bfb 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -33,6 +33,7 @@ namespace Pinetime { Matrix, Badge, IntrusionLog, + Sleep, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 8d6fbdeaf9..1d2f041989 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -4,6 +4,7 @@ else () 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::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index f1837995c9..441c078440 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, 0xf120, 0xf54c" + "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" } ], "bpp": 2, diff --git a/src/displayapp/screens/Sleep.cpp b/src/displayapp/screens/Sleep.cpp new file mode 100644 index 0000000000..19f20e8c75 --- /dev/null +++ b/src/displayapp/screens/Sleep.cpp @@ -0,0 +1,219 @@ +#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, light in the middle, still at the bottom + 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#05d9e8 L#\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 uint16_t light = night.asleep - night.still; + 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, + "#9d4edd STILL# %d:%02d #ff2a6d HR# %s\n" + "#05d9e8 LIGHT# %d:%02d #fcee0a LOW# %s\n" + "#ff2a6d AWAKE# %d:%02d #fcee0a EFF# %u%%", + night.still / 60, + night.still % 60, + heartRate, + light / 60, + light % 60, + lowest, + night.awake / 60, + night.awake % 60, + 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::Still: + 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/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 2c433c7e2b..586424e42e 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -85,7 +85,8 @@ SystemTask::SystemTask(Drivers::SpiMaster& spi, spiNorFlash, heartRateController, motionController, - fs) { + fs), + sleepTracker {fs} { } void SystemTask::Start() { @@ -141,6 +142,7 @@ void SystemTask::Work() { motionSensor.Init(); motionController.Init(motionSensor.DeviceType()); settingsController.Init(); + sleepTracker.Load(); displayApp.Register(this); displayApp.Register(&nimbleController.weather()); @@ -345,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()) { @@ -413,6 +416,23 @@ void SystemTask::Work() { } 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 (nrf_gpio_pin_read(PinMap::Button) == 0) { watchdog.Reload(); } diff --git a/src/systemtask/SystemTask.h b/src/systemtask/SystemTask.h index a083182df2..568faf02f8 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,6 +132,7 @@ 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(); From d887ad2b1581cd44b538d1cb32da1b4aaa7a470f Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Sun, 27 Sep 2026 03:44:49 +0000 Subject: [PATCH 7/8] Add Bluetooth tracker detector - Recognize AirTags and Find My accessories separated from their owner, SmartTag, Tile, Chipolo, Google Find My Device tags and DULT beacons from advertising data - Alert when a tracker shows up in 3+ scans over 30+ minutes, forget trackers not seen for 20 minutes - Optional automatic passive scan for 8 seconds every 5 minutes, setting stored in /trackers.dat, off by default - Trackers app scans continuously while open and lists trackers by signal strength for finding them; the live scan expires on its own when the screen turns off - Remember 32 devices for duplicate filtering while scanning --- README.md | 5 +- doc/cyberpunk/trackers.png | Bin 0 -> 6247 bytes src/CMakeLists.txt | 7 + src/components/ble/NimbleController.cpp | 126 +++++++++++++++++ src/components/ble/NimbleController.h | 41 ++++++ src/components/ble/TrackerDetector.cpp | 179 ++++++++++++++++++++++++ src/components/ble/TrackerDetector.h | 73 ++++++++++ src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/fonts/fonts.json | 2 +- src/displayapp/screens/Symbols.h | 5 +- src/displayapp/screens/Trackers.cpp | 178 +++++++++++++++++++++++ src/displayapp/screens/Trackers.h | 63 +++++++++ src/systemtask/Messages.h | 1 + src/systemtask/SystemTask.cpp | 32 +++++ src/systemtask/SystemTask.h | 3 + 17 files changed, 713 insertions(+), 5 deletions(-) create mode 100644 doc/cyberpunk/trackers.png create mode 100644 src/components/ble/TrackerDetector.cpp create mode 100644 src/components/ble/TrackerDetector.h create mode 100644 src/displayapp/screens/Trackers.cpp create mode 100644 src/displayapp/screens/Trackers.h diff --git a/README.md b/README.md index 72b075e03e..6b97789a06 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTim | ![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** | | | | -| ![Sleep](doc/cyberpunk/sleep.png) | | | | +| **Sleep** | **Trackers** | | | +| ![Sleep](doc/cyberpunk/sleep.png) | ![Trackers](doc/cyberpunk/trackers.png) | | | What's different from upstream: @@ -39,6 +39,7 @@ What's different from upstream: - **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 from wrist movement, no button to press. Shows time asleep, when you fell asleep and woke up, a hypnogram (awake / light / still), average and lowest heart rate and sleep efficiency, for tonight (marked LIVE while you're asleep) and the last 7 nights. It uses the accelerometer that is already running for step counting, so it costs no extra battery. Stages come from motion only: "still" means no movement at all, it's not the same as EEG deep sleep. Charging, or lying perfectly still for 2.5 hours, counts as off the wrist. Naps under an hour are ignored. The code lives in `src/components/sleep/`; the tuning constants are at the top of `SleepAnalysis.h` and `SleepTracker.h`. +- **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. diff --git a/doc/cyberpunk/trackers.png b/doc/cyberpunk/trackers.png new file mode 100644 index 0000000000000000000000000000000000000000..04d5d9d74533c78549feddab1c0e2f80c1e8ac21 GIT binary patch literal 6247 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 + } +} + +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); diff --git a/src/components/ble/NimbleController.h b/src/components/ble/NimbleController.h index 39e73bf9c3..c4e1c69034 100644 --- a/src/components/ble/NimbleController.h +++ b/src/components/ble/NimbleController.h @@ -19,6 +19,7 @@ #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" @@ -80,6 +81,38 @@ namespace Pinetime { // 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); @@ -119,6 +152,14 @@ namespace Pinetime { 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; uint8_t fastAdvCount = 0; 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/displayapp/UserApps.h b/src/displayapp/UserApps.h index 8034d9fe9f..79f5f58d5d 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -8,6 +8,7 @@ #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" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 3f28b96bfb..ffbf23dee5 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -34,6 +34,7 @@ namespace Pinetime { Badge, IntrusionLog, Sleep, + Trackers, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 1d2f041989..88d498b38b 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -5,6 +5,7 @@ else () 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") diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 441c078440..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, 0xf120, 0xf54c, 0xf186" + "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/Symbols.h b/src/displayapp/screens/Symbols.h index 01c454bbe6..e9c462222a 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,8 +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* 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/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/systemtask/Messages.h b/src/systemtask/Messages.h index 2bf7d8df65..bd632e48a8 100644 --- a/src/systemtask/Messages.h +++ b/src/systemtask/Messages.h @@ -31,6 +31,7 @@ namespace Pinetime { StopFileTransfer, BleRadioEnableToggle, OnIntrusion, + OnTrackerAlert, IntrusionLogChanged }; } diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 586424e42e..4c8e3d3300 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -380,6 +380,9 @@ void SystemTask::Work() { case Messages::OnIntrusion: RaiseIntrusionAlert(); break; + case Messages::OnTrackerAlert: + RaiseTrackerAlert(); + break; case Messages::IntrusionLogChanged: SaveIntrusionLog(); break; @@ -407,6 +410,13 @@ 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(); @@ -432,6 +442,9 @@ void SystemTask::Work() { sleepTracker.Save(); } SaveIntrusionLog(); + if (nimbleController.TrackerSettingsDirty()) { + nimbleController.SaveTrackerSettings(); + } } if (nrf_gpio_pin_read(PinMap::Button) == 0) { watchdog.Reload(); @@ -473,6 +486,25 @@ void SystemTask::RaiseIntrusionAlert() { } } +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 568faf02f8..3560604794 100644 --- a/src/systemtask/SystemTask.h +++ b/src/systemtask/SystemTask.h @@ -141,6 +141,9 @@ namespace Pinetime { // 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; From 5670201103d6d1eedf188764652ca3347d43fe6a Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Sun, 27 Sep 2026 14:45:16 +0000 Subject: [PATCH 8/8] Rework sleep detection with GGIR's validated methods The movement-count scoring marked most of a real night as awake. Replace it with the open methods used by GGIR for wrist accelerometers: - Record the forearm angle from 5 second averages, keep per-minute median angle change and posture changes over 5 degrees - Find the sleep period with HDCZA: 5 minute rolling median below the 10th percentile x 15 (0.13-0.5 degrees), drop blocks of 30 minutes or less, fill gaps under 60 minutes, keep the longest block - Score sleep inside it as sustained inactivity: no posture change for more than 5 minutes (van Hees 2015) - Detect non-wear as under 50mg range on 2 of 3 axes for 60 minutes - Drop the motion-based sleep stages, show time in bed, awake time, wake-ups and efficiency instead - Bump the history file version, nights stored by the old scoring are discarded --- README.md | 2 +- doc/cyberpunk/sleep.png | Bin 6745 -> 6747 bytes src/components/sleep/SleepAnalysis.cpp | 259 +++++++++++++------------ src/components/sleep/SleepAnalysis.h | 132 ++++++++----- src/components/sleep/SleepTracker.cpp | 161 ++++++++++----- src/components/sleep/SleepTracker.h | 62 ++++-- src/displayapp/screens/Sleep.cpp | 22 +-- 7 files changed, 386 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index 6b97789a06..9847e2aa8e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ What's different from upstream: - **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 from wrist movement, no button to press. Shows time asleep, when you fell asleep and woke up, a hypnogram (awake / light / still), average and lowest heart rate and sleep efficiency, for tonight (marked LIVE while you're asleep) and the last 7 nights. It uses the accelerometer that is already running for step counting, so it costs no extra battery. Stages come from motion only: "still" means no movement at all, it's not the same as EEG deep sleep. Charging, or lying perfectly still for 2.5 hours, counts as off the wrist. Naps under an hour are ignored. The code lives in `src/components/sleep/`; the tuning constants are at the top of `SleepAnalysis.h` and `SleepTracker.h`. +- **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*. diff --git a/doc/cyberpunk/sleep.png b/doc/cyberpunk/sleep.png index e08e24f6fad7158c9b151709416857665ec0ff77..332ef26804409ee5be9aaf7ea9119986cf00f1e3 100644 GIT binary patch literal 6747 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 literal 6745 zcmaiZcT`hPyKV#l1*J$A0TpSXcPY|45^CrPHPU+xO%VA>M|$r~0t7^AAT*VdC`F|R z(u9bV&_hQ~y!U+Pd}p1t?)_u-?7i2_thdfQ&pYvkAg$Y!cPRk?z-=9Ebz@?``?uX7 zCq5Oy>*)Z%-TOM~DyAWY_#!cnADZum))tN#fu<}#4$}^!gg#-ij<>Wjw%l0I{fu;~ ztTs8;AV!&>y(niHovOYK5D=_A^p0-G7$|ddbubFc0*{(};{&TSd=P%4CoHz*n@^|S zc6!&Nqz!DV|O+rd5dUe12>h1Rj z`GsXympig&=St7Na$fzxb}9f?K5ZS6d^G2nSiE|07Jl)(b@N?Y-w2K@f!Dcvgj962 z9ulVV?km;k#yjJse%t{mO)5rk8Gbm&Jl6+fvePBAHl^M!#2eCR&OOE>tUnJzd78ep{tp7E?u{R3uqYF zgC@_Cg?2Gjb?eUb!82w(aV~y5j-O*c{xp-$FZBIcy(x$ifN$US!4}#;MfVw$4?clJ zoa$=~mCG!69A{;pg*zo$)kHc{{ir_wBu~2Sb>{QLQ$AX*XmI!BwL`~P(JO%xA6KRD zg^Eer9^LjFe&4ino0>>2s!#Ls^i^-OL>$i>m0Hp~EQFQ{Vk%7PETncnWO=V#lGYH| zEbSnG!K`rBADB}Pk=ZFoyAX1W(I>SjPTWQ;F|*MliMUV~UrUu#;5x^kR#@KN!^LgC z1l<}#IX!QIMyihfENYsv-0Ex-Sj6U(ty^@L__4f#V&%e6@{e4(Lq5s0bI^^Gn&;;x zXPw$&b)%R0e+SJKpJy#Qx^Wu0%!##n!^fX=`0XiYBs6y}q6o}ZOKoeB*d7*)kM{(%`kE}YE?P-lmb#6+*A0}S`i4EOCd#g`1 z<2$MFex20@a9W;fN8^;HKAwH>1}~6>^nmT_hx}R0TvI?x4msk(7qzS;YRGFnDUMdi zyCkX6mDKJyRVksva!yl`141`d zQU`CG==}Jjy#P($V=fzqf|@KuX-gj@@0{$udbq-Iotk^}HNZTD4jyoWg-;Z4jg*Z1 zzFlb_;QFoqig`mj00}weE#*Gt=xaAvsz|gF)hk5kV_{9Q_K(JJk70x^N=L{azH zk)&^=@<~X0-yXXg?jJS}kYRI{l2>!pwy6g$qMZU@qUjgUKI^}ubmX%(uy)mQXZ-yXY(naRG;EK=RjO;a#` z(HsGpuh$qdQ}42w;}ecJ$e<(P8MzwszLR~Swo+F z+OO3}Hq;9bi-&`u@Rkrcavr~m8(_Mt{>smaSYb35+J>UssOV6oEpj&)!cU*mTs{fa|J%wqFKbM?$j*U#C z3|t3uq@IWm)Gn2`KwDK&UXXjlEDJoGmAmTR_k=!M|8p~WyO94hFV2a~*DjmSPBDlBQM}>Hd+i?r#lTPwIC*KDW_4PC zqlPWfD(2LR_yHw3la+;HwyLfA!cBJa%Gi006& zUqpc%tNWy72;rG%70;|*ohfcy!-m|In|&+Jao}gn-HzZgh(!y}Yx%nmuM!?nP#nLS zf1-5%hVV9v#kNa01+ug|S#+i)>{Vt%_wB%iR@f|0JLMLJyC@aJ^}c+VFjeI$VJZo18Th$Fx3aR}@>W5}Kce?Jx`ch@vld~)Bsm(=K7q@fJyhyY(BhkQkgpDWLOzvM z{n%RStWuj zh_Tyv*)4qbuu#P4kHVW5J`QY|A@%%WTv|ouyHn;%NQ%Z;D->S|#U7-XzK4|#zhf_p z%XK&q7Mw2-OZ*L`#Y#b}mG@kvcCP)d9#tNf+ z5I=)J<@o+wqtCHs`k!q~!xfKiDBql9=x{fYfbH+)^N*Wv)>)8QT6$}K zbhMxJAAnfek-T_6N+LtoO(|x4;UiU-DKl0aIm=lb#wWx4?drBGOyzcHz?n@MYv2N> zDS@RY)qINl>(I@*9s@+38Y%7dU%~+s-wCzANi|B1Et;}!jwT&?YhHZa%{GF$ zXR4)UG>?StaNfNL=P|ysMsSKybLc~QEk}*fGmGqz7tYJC6LqAAn;oosK~tqq@Fn34 znQ~PiD@)|P@Lrb*{jjHBFjF1|=ga`IDi9szeXl>_WWvqku&Dxp0#I#2O5^ObI@CFK z=qfr=^eCD?nt{Y%Rm8fV(Ow;G`pFg%PayzRcx4u@&wEI-@YwT;}hHtVfN#R+fN40MZ5K&2X4zX4bK_R}{{G+G@Cc!y$T!pAz8vbg%?Q zDM-=lL2CSB6?Vj~1CDX4N~P~PJ=H%uU1}!{{j=iVuto4je*HRRATd{bxtp|b`A3-d zeD$1HGT1RCF3u|=xUQZxFX+wdxo2FBZtX~@DD;Mg9yA|4Cess~ZWLmESPc?qlJQ)` z{$!4@enO)_oc3Fb;BE6j)__xqJJh4P65_Nycn{sVr#VL#eN>wN4%{nK%7tvtMo!Ct| zQd$ztuiC`Vs`i})x94kS+n>k0Gm-5A9-GKHCWsGJlyGTl-^HK-=(;{{sGwwW>9AIKLWQ=aZ@C9gD8z&^!X+G{MojdoEeis#6?ShZq4R>lG~_+ zDUyRX%}Ye{=68viKQ21F7+PERRxBTHb97tW2ejy{D>UU&Gz=VYmeia(+$9Gb^FdrP z`e<_C`sE6@lwpAWN!160d>I4q=&>k4Rk2=hWXQ*6IC#f@9n zmToG5ZYcLHMMwTSg8F~JEFLhC8<5?FSXT7oVh@KVvZRRbdc9g160)oSHqIsXqiz@_*QzO=k&|LYZTG{5Xzo8P+I zuV722>w_h>7+6p7{vx#@n|hhZT^@|U>Qk7CyGzFG#|#MuPCOl*(Tg_{fT9l@_?LE{ z#ydxvy8BB5%5ZNa7PIXxsFa+K6f*&o*P{Q0o0G)jq~y`cL~`ZS=GpyRnNx8`>I)wridB{j+I95e zSBR+^JRvIqYfTal_ z?b=+?YZH>2(!|$+115fBkS!w;Y0{Q#rsti|3ibg3&G5_Pa>rWH&TQ#CGgMGdUFCCQ z{Laa9SI%ck(E`X%M7EksLte_o()*+SFF9hpCImpK@WizS;mHWz&oK=H40RT7P5_kJXzVX!GiApZAb$GQ^rZs>X&UT5Pf8nTQ{ zTzZ<#vl?d2%ej*O(}-@ZoSR0XwcCkaTRqA+o*XevTN@Itdza9yAD>-8=iDTrt<AFBSyOnk@ZItjlSjh-~NoV z9Z+2|&1Wg1MnXVWhq8E@liX?P6EHIed|NJ-i6u|=hr|qPCP*i4FUKZ8r4~Pre|m`+ zv*D5SAhj2sC^AswXhTDVjZ&gz7L>F}!NkbNl!E6q*b#DYXQP%#OJ?=2ct-)-f80L5 zE%mr9*dB+hU-1dn9`RD$^Q$lXr5%)UpVVq^0*dIKSbd5Lnw8;Mdne!`w7Vf<%b#Vc zUZWxv(zw<93X1BgtQYBFlWBf9OkrbR&^Az3XyCL8xNo=n7rXp_xi^}m@z`Jy2t70l zN_joW$ar7`^@66Mqp<5K*kb|r#LrjaQH|a~@r9#Re5u)jygAjZ9-#5xSXVJ_tI7aQ zqjHTvbj;_2P~1vfXvb5C$7ogKxr#;@QwZAN&_t>CvZlub=SBhsW}%YY^DWq8KHt=B z+o#O&_%84h)4t7+GpwjzpL$+)BM^fj{E7Q>;b-Mx3>np0JOAu%?B|z9=sC*`w-pwd zhWQH?CB+<82%BI~+VpjG6}93sg)GTEh1p-Oy0mMz?EFAuuZnPG_vdfg29yZ#+H@CgEb}p^Kd<>fQav|Er*!BAIJDPljfRp> zsExvRzDKxZsItCZYJ(xPtw9!+Qq}}cvnyHN{NmK`uCLn6K*RQ9)R+BxVvE_`_0UGo{F&+< z3Xlu~O9=0WoPu>)4F3zW_vQprGoY&W=3KIvPw5G0#m(xu(owNmV6er%q`nSjMx#%Y!R5)-<;=>#snNC)>bR>1NpiY_@?PiIsvqOR2?82kIH3 z3PtDrWE)_PaBwEl2hR4r`RsH5v^i2q#OguC1JTZMr|HS6D1D)1nOGULS%yVuI5oqapTt1ZW;HtN={E@@^=QBf)kcmJ~(`@d@n|CK>cYe`a(^;NLakdghR zebsH52O@Di&MoITlVitgftaD3=PLw6!nTF8BC=;H^qZ=jWpp8jnQ(Z~J77`0 z-YMclchkL@qvy9wN)}lPud~Mo6Ek55!K`GS3bJYfIY=O5`Eck)&_-;uG-xzjrbO!tPKOr*n>Nc?Dr*ecAN;m2qiQf# zfEU!?C$4Jj7p^_BXFnR44b$J4?r+#Je!u;$RCm@qqosMSYtGe+oBp3JwGBe3ib5d6 z9Wb&{!D}k8I;Kcv^o&PdM_9;!Rg+cr<)(Gut*wek`b|BjwRCfh>(Wm_3c=oiGBHRF z4&|HNi~cPx&fH))GVU;N26L{tor$Z@98gP#1Qhs+Xw~(FhXVza(~Li4NVqEV^T!>U zHQ1&|=UCc8*g`pFD32vRO5@$x1-@(k+(kXIrdPK<#iRKNGhjmGqle`S0y6tNaUf}E ziffaEVTyU6rJ3 z*cQ*EbamNi-m+h{PZ|llvjSa~n|rj!3vB8GGE2!SDzrA;t(wrt!i6HH3NUEXrQFw#U!qTSWAnoZ!lK7j+et=g;hX6jHVJ z^j=fq+3C#mM+5?R0^*{?;i)xtE^ukiCZ;Bu1I<)=o_-?yjJc^QVVpAJ8r&uf8fKpF z8f+;_l%|#)lROHJvK*U}Quyk+_0fE@6evDl?Ok^xrnT{Tnr3dPJaKW~$t+46Ne#zx zNmvJB@dG8VM?*_p>-fHa#Oi&miP#DB^+YZTvGk%x?QPkM_x68qHMZjeCq}Y+TQT~5 zjj>2BsZP$vD1I(F0@L)=qnzMnRciQph1Z-RrFyMm-QdS+0`qdYTJ4LSmgZ8n-9B(y zt0bB;K#yC)%I=S*PDRdlx-S?KTEP&Gf8D_R@1#GtYH + using namespace Pinetime::Controllers::Sleep; namespace { - bool IsValid(uint8_t level) { - return level <= LevelMax; - } - - bool ScoreAsleep(const Window& window, uint16_t index) { - const uint8_t own = window[index]; - if (!IsValid(own)) { - return false; - } - uint8_t active = 0; - const int32_t from = static_cast(index) - WindowBefore; - const int32_t to = static_cast(index) + WindowAfter; - for (int32_t i = from; i <= to; i++) { - if (i < 0 || i >= window.Size()) { - continue; + // 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); } - const uint8_t level = window[i]; - if (!IsValid(level) || level > ActiveLevel) { - active++; + } + 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 active <= MaxActiveInWindow; + return values[n / 2]; } - // Count of consecutive minutes with the same asleep value starting at index, going forward or backward - uint16_t RunLength(const uint8_t* bits, uint16_t size, uint16_t index, bool value, int8_t direction) { - uint16_t length = 0; - int32_t i = index; - while (i >= 0 && i < size && GetBit(bits, i) == value) { - length++; - i += direction; + // 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 length; + return i - index; } -} -Stage Pinetime::Controllers::Sleep::StageAt(const Window& window, const uint8_t* asleepBits, uint16_t index) { - if (!IsValid(window[index])) { - return Stage::None; - } - if (!GetBit(asleepBits, index)) { - return Stage::Awake; - } - const int32_t from = static_cast(index) - StillRadius; - const int32_t to = static_cast(index) + StillRadius; - for (int32_t i = from; i <= to; i++) { - if (i < 0 || i >= window.Size()) { - continue; - } - if (window[i] != 0) { - return Stage::Light; + 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); } } - return Stage::Still; } -Session Pinetime::Controllers::Sleep::Analyze(const Window& window, uint8_t* asleepBits) { +Session Pinetime::Controllers::Sleep::Analyze(const Window& window, const Scratch& scratch) { const uint16_t size = window.Size(); - - for (uint16_t i = 0; i < size; i++) { - SetBit(asleepBits, i, ScoreAsleep(window, i)); + Session session; + if (size == 0) { + return session; } - // Rescore brief arousals (1-2 minutes) inside solid sleep as sleep, a turn-over shouldn't count as waking up + // 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++) { - if (GetBit(asleepBits, i) || !IsValid(window[i])) { - continue; + const uint8_t value = RollingMedian(window, i); + if (value != Invalid) { + scratch.histogram[value]++; + total++; } - const uint16_t awakeRun = RunLength(asleepBits, size, i, false, 1); - const uint16_t end = i + awakeRun; - if (awakeRun <= 2 && i > 0 && end < size && RunLength(asleepBits, size, i - 1, true, -1) >= OnsetMinutes && - RunLength(asleepBits, size, end, true, 1) >= OnsetMinutes) { - bool allValid = true; - for (uint16_t j = i; j < end; j++) { - allValid = allValid && IsValid(window[j]); - } - if (allValid) { - for (uint16_t j = i; j < end; j++) { - SetBit(asleepBits, j, true); - } - } + } + 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; } - i = end; } + const uint16_t threshold = std::clamp(percentile * ThresholdMultiplier, ThresholdMin, ThresholdMax); + session.threshold = threshold; - Session best; - bool inBlock = false; - uint16_t blockStart = 0; - uint16_t lastAsleep = 0; - uint16_t asleepCount = 0; - uint16_t run = 0; + // 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); + } - auto closeBlock = [&](bool atEnd) { - // Asleep at least half of the time in bed, otherwise it's restless noise rather than a night - const bool efficient = asleepCount * 2 >= (lastAsleep - blockStart + 1); - if (inBlock && efficient && asleepCount >= MinAsleepMinutes && asleepCount >= best.asleep) { - best.found = true; - best.start = blockStart; - best.end = lastAsleep; - best.asleep = asleepCount; - best.ongoing = atEnd && (size - 1 - lastAsleep) <= OngoingMinutes; + // 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); } - inBlock = false; - asleepCount = 0; - }; + i += run; + } - for (uint16_t i = 0; i < size; i++) { - const bool valid = IsValid(window[i]); - const bool asleep = valid && GetBit(asleepBits, i); - run = asleep ? run + 1 : 0; + // 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; + } - if (inBlock) { - // Off-wrist minutes don't end the session by themselves (e.g. a short charge), they count as unknown - if ((i - lastAsleep) > MaxWakeGapMinutes) { - closeBlock(false); - } else if (asleep) { - lastAsleep = i; - asleepCount++; + // 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 (!inBlock && run >= OnsetMinutes) { - inBlock = true; - blockStart = i + 1 - OnsetMinutes; - lastAsleep = i; - asleepCount = OnsetMinutes; + 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; } - closeBlock(true); - if (best.found) { - best.asleep = 0; - for (uint16_t i = best.start; i <= best.end; i++) { - switch (StageAt(window, asleepBits, i)) { - case Stage::Still: - best.still++; - best.asleep++; - break; - case Stage::Light: - best.light++; - best.asleep++; - break; - case Stage::Awake: - best.awake++; - break; - case Stage::None: - break; - } + 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; } } - return best; + + 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 index eac9678028..69bf5bbb4c 100644 --- a/src/components/sleep/SleepAnalysis.h +++ b/src/components/sleep/SleepAnalysis.h @@ -3,85 +3,115 @@ #include #include -// Motion based sleep scoring (actigraphy). Pure functions without FreeRTOS dependencies so they can be tested on a PC. +// Sleep detection from wrist accelerometer data, following the open GGIR methods: // -// Input is one activity level per minute: 0 means no movement above the sensor noise floor, -// higher values mean more movement. A minute is scored asleep when a weighted window of the -// surrounding minutes has little sustained movement (in the spirit of the Cole-Kripke algorithm): -// a turn-over is one or two active minutes, being awake is movement minute after minute. -// The longest block of sleep, allowing short wake-ups, is reported as the session. -// Stages are estimated from motion only: "still" means asleep without any movement for -// several minutes around it, "light" is asleep with small movements. +// - 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 uint8_t LevelMax = 253; - static constexpr uint8_t LevelOffWrist = 254; - static constexpr uint8_t LevelNoData = 255; - - // Tuning - static constexpr uint8_t ActiveLevel = 3; // A minute above this level counts as active - static constexpr uint8_t WindowBefore = 4; // Scoring window: minutes before... - static constexpr uint8_t WindowAfter = 2; // ...and after the scored minute - static constexpr uint8_t MaxActiveInWindow = 2; // More active minutes than this in the window means awake - static constexpr uint8_t OnsetMinutes = 10; // Consecutive asleep minutes needed to start a session - static constexpr uint8_t MaxWakeGapMinutes = 60; // A longer awake or off-wrist stretch ends the session - static constexpr uint16_t MinAsleepMinutes = 60; // Shorter sessions are ignored - static constexpr uint8_t OngoingMinutes = 15; // Session ending this close to now is still in progress - static constexpr uint8_t StillRadius = 5; // Minutes on each side that must be motionless for "still" - - enum class Stage : uint8_t { None = 0, Awake = 1, Light = 2, Still = 3 }; - - // Chronological view over a ring buffer: index 0 is the oldest minute, Size() - 1 the newest + 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* ring, uint16_t capacity, uint16_t newest, uint16_t count) - : ring {ring}, capacity {capacity}, newest {newest}, count {count} { + 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; } - uint8_t operator[](uint16_t index) const { - return ring[(newest + capacity + 1 - count + index) % capacity]; + // 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: - const uint8_t* ring; + 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 asleep minute - uint16_t end = 0; // Window index of the last asleep minute + 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 still = 0; - uint16_t light = 0; uint16_t awake = 0; + uint8_t wakeUps = 0; + uint8_t threshold = 0; // HDCZA threshold that was used, DeltaUnits }; - // Scores every minute of the window into asleepBits (at least Size() / 8 + 1 bytes) and finds the main session - Session Analyze(const Window& window, uint8_t* asleepBits); - - // Stage of a minute, only valid after Analyze() filled asleepBits - Stage StageAt(const Window& window, const uint8_t* asleepBits, uint16_t index); - - inline bool GetBit(const uint8_t* bits, uint16_t index) { - return (bits[index / 8] & (1 << (index % 8))) != 0; - } + Session Analyze(const Window& window, const Scratch& scratch); - 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)); - } - } + // 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 index 63ff9f24c3..46665eedcb 100644 --- a/src/components/sleep/SleepTracker.cpp +++ b/src/components/sleep/SleepTracker.cpp @@ -1,6 +1,7 @@ #include "components/sleep/SleepTracker.h" #include +#include #include #include "components/fs/FS.h" @@ -8,6 +9,7 @@ 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; @@ -17,7 +19,7 @@ namespace { SleepTracker::SleepTracker(FS& fs) : fs {fs} { mutex = xSemaphoreCreateMutex(); xSemaphoreGive(mutex); - levels.fill(Sleep::LevelNoData); + medians.fill(Sleep::Invalid); } void SleepTracker::Load() { @@ -68,48 +70,105 @@ void SleepTracker::OnMotion(int16_t x, int16_t y, int16_t z, uint32_t localMinut currentMinute = localMinute; } xSemaphoreGive(mutex); - movement = 0; + deltaCount = 0; + minutePostureChange = false; minuteOffWrist = false; minuteHeartRate = 0; + minuteHasSamples = false; } - if (havePrevious) { - const int32_t change = std::abs(x - previousX) + std::abs(y - previousY) + std::abs(z - previousZ); - if (change > NoiseFloor) { - movement += change - NoiseFloor; - } + // 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]; } - havePrevious = true; - previousX = x; - previousY = y; - previousZ = z; - + 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 level = static_cast(std::min(movement / LevelDivider, Sleep::LevelMax)); - if (minuteOffWrist) { - level = Sleep::LevelOffWrist; + 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]; } - // Nobody lies perfectly still for hours: the watch is on the nightstand - stillMinutes = (level == 0) ? stillMinutes + 1 : 0; - if (stillMinutes >= OffWristStillMinutes) { - if (stillMinutes == OffWristStillMinutes) { - for (uint16_t i = 0; i < OffWristStillMinutes - 1 && i < count; i++) { - levels[(newest + Minutes - i) % Minutes] = Sleep::LevelOffWrist; + // 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]); } } - level = Sleep::LevelOffWrist; + 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; } - newest = (newest + 1) % Minutes; - levels[newest] = level; - count = std::min(count + 1, Minutes); + 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]; @@ -125,17 +184,24 @@ void SleepTracker::AdvanceTo(uint32_t minute) { } if (m != minute) { // Minutes without samples (shouldn't happen, the system task runs all the time) - newest = (newest + 1) % Minutes; - levels[newest] = Sleep::LevelNoData; - count = std::min(count + 1, Minutes); + Push(Sleep::Invalid, false); stillMinutes = 0; } } currentMinute = minute; } -bool SleepTracker::Build(const Sleep::Session& session, Night& night) { - const Sleep::Window window(levels.data(), Minutes, newest, count); +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; @@ -143,41 +209,32 @@ bool SleepTracker::Build(const Sleep::Session& session, Night& night) { night.start = (firstMinute + session.start) * 60; night.inBed = session.end - session.start + 1; night.asleep = session.asleep; - night.still = session.still; 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 still = 0; - uint8_t light = 0; + uint8_t asleep = 0; for (uint8_t m = 0; m < EpochMinutes; m++) { const uint16_t index = session.start + epoch * EpochMinutes + m; - if (index > session.end) { - break; - } - switch (Sleep::StageAt(window, asleepBits.data(), index)) { + switch (Sleep::StageAt(window, scratch, session, index)) { case Sleep::Stage::Awake: awake++; break; - case Sleep::Stage::Still: - still++; - break; - case Sleep::Stage::Light: - light++; + case Sleep::Stage::Asleep: + asleep++; break; case Sleep::Stage::None: break; } } Sleep::Stage stage = Sleep::Stage::None; - if (awake >= 2) { + if (awake > asleep) { stage = Sleep::Stage::Awake; - } else if (still >= 3) { - stage = Sleep::Stage::Still; - } else if (still + light > 0) { - stage = Sleep::Stage::Light; + } else if (asleep > 0) { + stage = Sleep::Stage::Asleep; } night.stages[epoch / 4] |= static_cast(stage) << ((epoch % 4) * 2); } @@ -201,16 +258,16 @@ bool SleepTracker::Build(const Sleep::Session& session, Night& night) { night.avgHeartRate = sum / slots; night.minHeartRate = minimum; } - return true; } bool SleepTracker::Live(Night& night) { xSemaphoreTake(mutex, portMAX_DELAY); - const Sleep::Window window(levels.data(), Minutes, newest, count); - const Sleep::Session session = Sleep::Analyze(window, asleepBits.data()); - const bool found = session.found && Build(session, night); + const Sleep::Session session = Sleep::Analyze(MakeWindow(), MakeScratch()); + if (session.found) { + Build(session, night); + } xSemaphoreGive(mutex); - return found; + return session.found; } void SleepTracker::Finalize() { diff --git a/src/components/sleep/SleepTracker.h b/src/components/sleep/SleepTracker.h index f1d37a0f3c..42d8b588e4 100644 --- a/src/components/sleep/SleepTracker.h +++ b/src/components/sleep/SleepTracker.h @@ -11,8 +11,9 @@ namespace Pinetime { namespace Controllers { class FS; - // Records wrist movement per minute for the last 24 hours, detects sleep from it - // and keeps a short history of nights on the filesystem. + // 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; @@ -22,14 +23,15 @@ namespace Pinetime { struct Night { uint32_t start; // Local time, seconds since epoch - uint16_t inBed; // Minutes from falling asleep to waking up + uint16_t inBed; // Minutes in the sleep period uint16_t asleep; // Minutes - uint16_t still; 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 { @@ -64,25 +66,36 @@ namespace Pinetime { private: static constexpr uint16_t HeartRateSlots = Minutes / 10; - static constexpr int16_t NoiseFloor = 24; // Sample-to-sample change (mg, summed over axes) ignored as noise - static constexpr uint8_t LevelDivider = 16; // Movement sum per minute divided by this is the level - static constexpr uint16_t OffWristStillMinutes = 150; // No movement at all for this long: watch is off the wrist - static constexpr uint32_t magic = 0x50454c53; // "SLEP" - static constexpr uint8_t fileVersion = 1; + 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); - bool Build(const Sleep::Session& session, Night& night); + 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; - std::array levels; - std::array asleepBits {}; + // 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; @@ -92,14 +105,29 @@ namespace Pinetime { uint32_t currentMinute = 0; bool started = false; - uint32_t movement = 0; + + // 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; - bool havePrevious = false; - int16_t previousX = 0; - int16_t previousY = 0; - int16_t previousZ = 0; std::array history {}; uint8_t historyCount = 0; diff --git a/src/displayapp/screens/Sleep.cpp b/src/displayapp/screens/Sleep.cpp index 19f20e8c75..0ad8338914 100644 --- a/src/displayapp/screens/Sleep.cpp +++ b/src/displayapp/screens/Sleep.cpp @@ -70,11 +70,11 @@ Sleep::Sleep(Controllers::SleepTracker& tracker) : tracker {tracker} { labelTimes = CreateLabel(Colors::neonYellow, 0, 56); lv_label_set_align(labelTimes, LV_LABEL_ALIGN_RIGHT); - // Hypnogram: awake at the top, light in the middle, still at the bottom + // 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#05d9e8 L#\n#9d4edd S#"); + 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); @@ -154,7 +154,6 @@ void Sleep::ShowPage() { DrawGraph(night); - const uint16_t light = night.asleep - night.still; const unsigned efficiency = night.inBed == 0 ? 0 : (night.asleep * 100U) / night.inBed; char heartRate[8]; char lowest[8]; @@ -166,17 +165,16 @@ void Sleep::ShowPage() { snprintf(lowest, sizeof(lowest), "--"); } lv_label_set_text_fmt(labelStats, - "#9d4edd STILL# %d:%02d #ff2a6d HR# %s\n" - "#05d9e8 LIGHT# %d:%02d #fcee0a LOW# %s\n" - "#ff2a6d AWAKE# %d:%02d #fcee0a EFF# %u%%", - night.still / 60, - night.still % 60, + "#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, - light / 60, - light % 60, - lowest, night.awake / 60, night.awake % 60, + lowest, + night.wakeUps, efficiency); } @@ -189,7 +187,7 @@ void Sleep::DrawGraph(const Night& night) { case Stage::Awake: y = 0; break; - case Stage::Still: + case Stage::Asleep: y = 2 * rowHeight; break; default: