From a0df0a207c416d3d17db8568ded301a02ebd2f70 Mon Sep 17 00:00:00 2001 From: Kawin Pechetratanapanit <39807451+kawinie@users.noreply.github.com> Date: Wed, 13 May 2026 02:41:51 -0700 Subject: [PATCH] feat(v2): input lock via L+R long-press Holding L+R for the long-press threshold toggles a lock on NormalStage and EnergyStage. While locked, button and encoder events drop except the L+R combo itself, so the same gesture clears the lock. A padlock glyph renders in the top-right corner. --- include/v2/events.h | 4 + include/v2/images.h | 17 +++ include/v2/stages/energy/energy_view.h | 19 ++- include/v2/stages/energy_stage.h | 17 ++- include/v2/stages/normal/normal_view.h | 29 +++-- include/v2/stages/normal_stage.h | 20 +++ test/test_v2_energy/test.cpp | 117 +++++++++++++++++ test/test_v2_normal/test.cpp | 171 +++++++++++++++++++++++++ 8 files changed, 381 insertions(+), 13 deletions(-) diff --git a/include/v2/events.h b/include/v2/events.h index 450d241..4f01ce3 100644 --- a/include/v2/events.h +++ b/include/v2/events.h @@ -50,6 +50,10 @@ namespace pocketpd { bool l_short() const { return id == ButtonId::L && gesture == Gesture::SHORT; } + + bool lr_long() const { + return id == ButtonId::L_R && gesture == Gesture::LONG; + } }; /** diff --git a/include/v2/images.h b/include/v2/images.h index c18bfaa..790fa10 100644 --- a/include/v2/images.h +++ b/include/v2/images.h @@ -173,6 +173,23 @@ namespace pocketpd::bitmap { 0x00, 0x00, 0x00, 0x00, }; + /** + * @brief Padlock, 8x8px. Used as the input-lock indicator on NormalStage / EnergyStage. + * ``` + * ..####.. + * .#....#. + * .#....#. + * ######## + * ######## + * ###..### + * ######## + * ######## + * ``` + */ + inline constexpr std::array PADLOCK = { + 0x3C, 0x42, 0x42, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, + }; + // --- Arrow rotation animation, 20x20px per frame, 28 frames total --- // Frames 12-25 share identical bitmap data (held position). diff --git a/include/v2/stages/energy/energy_view.h b/include/v2/stages/energy/energy_view.h index 3144556..4af375e 100644 --- a/include/v2/stages/energy/energy_view.h +++ b/include/v2/stages/energy/energy_view.h @@ -26,6 +26,7 @@ namespace pocketpd { uint32_t total_seconds = 0; uint8_t arrow_frame = 0; bool output_enabled = false; + bool locked = false; }; class EnergyView { @@ -51,6 +52,11 @@ namespace pocketpd { using Display = tempo::Display; public: + static constexpr uint8_t PADLOCK_X = 116; + static constexpr uint8_t PADLOCK_Y = 0; + static constexpr uint8_t PADLOCK_W = 8; + static constexpr uint8_t PADLOCK_H = 8; + static void render(Display& display, const EnergyViewModel& vm) { display.clear(); std::array buf{}; @@ -62,32 +68,37 @@ namespace pocketpd { display.draw_xbm(ARROW_X, ARROW_Y, ARROW_W, ARROW_H, bitmap); } - // Top row — live V/A display.set_font(tempo::Font::BASE); + // Mid — Voltage Current format_milli(buf, vm.snapshot.vbus_mv, 'V'); display.draw_text(V_X, ROW2_Y - 10, buf.data()); format_milli(buf, vm.snapshot.current_ma, 'A'); display.draw_text(A_X, ROW2_Y + 5, buf.data()); + // Bottom row — Wh Time Ah format_time(buf, vm.total_seconds); display.draw_text(T_X, ROW3_Y, buf.data()); - format_auto(buf, vm.accumulated_wh); draw_value(display, COL1_X, ROW3_Y, buf.data(), "Wh"); format_auto(buf, vm.accumulated_ah); draw_value(display, COL2_X, ROW3_Y, buf.data(), "Ah"); - // Mid — watts and watt-hours + // The big W label in the middle display.set_font(tempo::Font::XL); const double watts = (vm.snapshot.vbus_mv * vm.snapshot.current_ma) / 1'000'000.0; - format_auto(buf, watts); draw_value(display, COL1_X, ROW2_Y, buf.data(), "W"); + if (vm.locked) { + display.draw_xbm( + PADLOCK_X, PADLOCK_Y, PADLOCK_W, PADLOCK_H, bitmap::PADLOCK.data() + ); + } + display.flush(); } diff --git a/include/v2/stages/energy_stage.h b/include/v2/stages/energy_stage.h index 5cb8a57..ee8706b 100644 --- a/include/v2/stages/energy_stage.h +++ b/include/v2/stages/energy_stage.h @@ -38,6 +38,7 @@ namespace pocketpd { int8_t m_active_pdo_index = -1; tempo::IntervalTimer m_render_interval{40}; uint8_t m_arrow_frame = 0; + bool m_locked = false; static constexpr uint32_t SENSOR_EMA_DEN = 4; @@ -55,11 +56,16 @@ namespace pocketpd { return m_active_pdo_index; } + bool locked() const { + return m_locked; + } + void prepare(int8_t pdo_index = -1) { m_active_pdo_index = pdo_index; } void on_enter(Conductor&) override { + m_locked = false; log.info("Entered Energy screen pdo_index={}", m_active_pdo_index); draw(); } @@ -73,11 +79,19 @@ namespace pocketpd { void on_event(Conductor& conductor, const Event& event, uint32_t) override { auto handler = tempo::overloaded{ [&](const ButtonEvent& evt) { + // L+R combo always reachable; must precede lock guard so a locked screen can unlock. + if (evt.lr_long()) { + m_locked = !m_locked; + return; + } + if (m_locked) { + return; + } + if (evt.r_short()) { m_output_gate.toggle(); return; } - if (evt.r_long()) { conductor.request(m_active_pdo_index); } @@ -115,6 +129,7 @@ namespace pocketpd { .total_seconds = m_energy.total_seconds, .arrow_frame = m_arrow_frame, .output_enabled = m_output_gate.is_enabled(), + .locked = m_locked, }; } diff --git a/include/v2/stages/normal/normal_view.h b/include/v2/stages/normal/normal_view.h index fd156e7..07b252b 100644 --- a/include/v2/stages/normal/normal_view.h +++ b/include/v2/stages/normal/normal_view.h @@ -25,6 +25,7 @@ namespace pocketpd { bool has_profile = false; bool is_pps = false; bool output_enabled = false; + bool locked = false; uint8_t arrow_frame = 0; SensorSnapshot snapshot{}; @@ -59,6 +60,11 @@ namespace pocketpd { static constexpr uint8_t CURSOR_W = 7; public: + static constexpr uint8_t PADLOCK_X = 116; + static constexpr uint8_t PADLOCK_Y = 0; + static constexpr uint8_t PADLOCK_W = 8; + static constexpr uint8_t PADLOCK_H = 8; + static void render(tempo::Display& d, const NormalViewModel& vm) { d.clear(); @@ -81,7 +87,6 @@ namespace pocketpd { const auto INDEX_X = STATUS_X - d.text_width(buf.data()) - 2; d.draw_text(INDEX_X, 63, buf.data()); d.draw_text(STATUS_X, 64, vm.is_pps ? "PPS" : "PDO"); - d.draw_text(INDEX_X + 2, 8, vm.output_enabled ? "ON" : "OFF"); if (vm.is_pps) { draw_target_pps(d, vm, buf); @@ -94,12 +99,22 @@ namespace pocketpd { d.draw_xbm(ARROW_X, ARROW_Y, ARROW_W, ARROW_H, bitmap::ARROW_FRAMES.at(frame)); } + if (vm.locked) { + d.draw_text(INDEX_X + 2, 8, vm.output_enabled ? "ON" : "OFF"); + d.draw_xbm(PADLOCK_X, PADLOCK_Y, PADLOCK_W, PADLOCK_H, bitmap::PADLOCK.data()); + } else { + d.draw_text(PADLOCK_X - 6, 8, vm.output_enabled ? "ON" : "OFF"); + } + d.flush(); } private: static void draw_measured( - tempo::Display& d, const char* label, uint8_t y, uint32_t value, + tempo::Display& d, + const char* label, + uint8_t y, + uint32_t value, std::array& buf ) { d.draw_text(1, y, label); @@ -110,9 +125,8 @@ namespace pocketpd { d.draw_text(static_cast(TARGET_RIGHT_X - width), y, buf.data()); } - static void draw_target_fixed( - tempo::Display& d, const NormalViewModel& vm, std::array& buf - ) { + static void + draw_target_fixed(tempo::Display& d, const NormalViewModel& vm, std::array& buf) { std::snprintf(buf.data(), buf.size(), "%ld mV", static_cast(vm.pdo_max_mv)); auto w = d.text_width(buf.data()); d.draw_text(static_cast(TARGET_RIGHT_X - w), V_TARGET_Y, buf.data()); @@ -122,9 +136,8 @@ namespace pocketpd { d.draw_text(static_cast(TARGET_RIGHT_X - w), A_TARGET_Y, buf.data()); } - static void draw_target_pps( - tempo::Display& d, const NormalViewModel& vm, std::array& buf - ) { + static void + draw_target_pps(tempo::Display& d, const NormalViewModel& vm, std::array& buf) { std::snprintf(buf.data(), buf.size(), "%ld mV", static_cast(vm.target_mv)); auto w = d.text_width(buf.data()); d.draw_text(static_cast(TARGET_RIGHT_X - w), V_TARGET_Y, buf.data()); diff --git a/include/v2/stages/normal_stage.h b/include/v2/stages/normal_stage.h index 6b6d89a..95be060 100644 --- a/include/v2/stages/normal_stage.h +++ b/include/v2/stages/normal_stage.h @@ -37,6 +37,7 @@ namespace pocketpd { int8_t m_active_pdo_index = -1; int8_t m_last_active_index = -1; + bool m_locked = false; Mode m_mode; IntervalTimer m_render_interval{40}; @@ -59,6 +60,10 @@ namespace pocketpd { return m_active_pdo_index; } + bool locked() const { + return m_locked; + } + const Mode& mode() const { return m_mode; } @@ -89,6 +94,8 @@ namespace pocketpd { } void on_enter(Conductor&) override { + m_locked = false; + // Turns off output if the profile has changed if (m_active_pdo_index != m_last_active_index) { m_output_gate.disable(); @@ -126,6 +133,15 @@ namespace pocketpd { void on_event(Conductor& conductor, const Event& event, uint32_t) override { auto handler = tempo::overloaded{ [&](const ButtonEvent& evt) { + // L+R combo always reachable; must precede lock guard so a locked screen can unlock. + if (evt.lr_long()) { + m_locked = !m_locked; + return; + } + if (m_locked) { + return; + } + if (evt.r_short()) { m_output_gate.toggle(); return; @@ -147,6 +163,9 @@ namespace pocketpd { } }, [&](const EncoderEvent& evt) { + if (m_locked) { + return; + } auto* pps = std::get_if(&m_mode); if (pps == nullptr) { return; @@ -225,6 +244,7 @@ namespace pocketpd { .active_pdo_index = m_active_pdo_index, .has_profile = m_active_pdo_index >= 0, .output_enabled = m_output_gate.is_enabled(), + .locked = m_locked, .arrow_frame = m_arrow_frame, .snapshot = m_snapshot, }; diff --git a/test/test_v2_energy/test.cpp b/test/test_v2_energy/test.cpp index 534039b..cd5813f 100644 --- a/test/test_v2_energy/test.cpp +++ b/test/test_v2_energy/test.cpp @@ -19,6 +19,8 @@ #include #include "v2/app.h" +#include "v2/images.h" +#include "v2/stages/energy/energy_view.h" #include "v2/stages/energy_stage.h" #include "v2/stages/normal_stage.h" #include "v2/tasks/energy_task.h" @@ -279,6 +281,121 @@ TEST(EnergyStage, EnergyEventDriveDisplayedAccumulators) { energy.on_tick(conductor, 100); } +// ——— EnergyStage lock ———————————————————————————————————————————————————————— + +TEST(EnergyStage, ComboLongTogglesLock) { + NiceMock display; + NiceMock gate; + + EnergyStage stage(display, gate); + TestConductor conductor; + conductor.register_stage(stage); + stage.prepare(0); + conductor.start(); + + EXPECT_FALSE(stage.locked()); + stage.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + EXPECT_TRUE(stage.locked()); + stage.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + EXPECT_FALSE(stage.locked()); +} + +TEST(EnergyStage, LockedIgnoresRShort) { + NiceMock display; + NiceMock gate; + EXPECT_CALL(gate, disable()).Times(::testing::AnyNumber()); + EXPECT_CALL(gate, enable()).Times(0); + + EnergyStage stage(display, gate); + TestConductor conductor; + conductor.register_stage(stage); + stage.prepare(0); + conductor.start(); + + stage.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(stage.locked()); + + stage.on_event(conductor, ButtonEvent{ButtonId::R, Gesture::SHORT}, 0); +} + +TEST(EnergyStage, OnEnterResetsLocked) { + NiceMock display; + NiceMock gate; + + EnergyStage stage(display, gate); + TestConductor conductor; + conductor.register_stage(stage); + stage.prepare(0); + conductor.start(); + + stage.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(stage.locked()); + + stage.prepare(0); + stage.on_enter(conductor); + EXPECT_FALSE(stage.locked()); +} + +// ——— EnergyView render —————————————————————————————————————————————————————— + +TEST(EnergyView, LockedRendersPadlock) { + using ::testing::_; + NiceMock display; + + EXPECT_CALL( + display, + draw_xbm( + EnergyView::PADLOCK_X, EnergyView::PADLOCK_Y, + EnergyView::PADLOCK_W, EnergyView::PADLOCK_H, + bitmap::PADLOCK.data() + ) + ).Times(1); + + EnergyViewModel vm{}; + vm.output_enabled = false; + vm.locked = true; + + EnergyView::render(display, vm); +} + +TEST(EnergyView, UnlockedDoesNotDrawPadlock) { + using ::testing::_; + NiceMock display; + + EXPECT_CALL( + display, + draw_xbm(_, _, EnergyView::PADLOCK_W, EnergyView::PADLOCK_H, bitmap::PADLOCK.data()) + ).Times(0); + + EnergyViewModel vm{}; + vm.output_enabled = false; + vm.locked = false; + + EnergyView::render(display, vm); +} + +TEST(EnergyStage, LockedIgnoresRLong) { + NiceMock display; + NiceMock gate; + NiceMock sink; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + + EnergyStage energy(display, gate); + NormalStage normal(display, sink, gate); + TestConductor conductor; + conductor.register_stage(energy); + conductor.register_stage(normal); + energy.prepare(0); + conductor.start(); + + energy.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(energy.locked()); + + energy.on_event(conductor, ButtonEvent{ButtonId::R, Gesture::LONG}, 0); + EXPECT_TRUE(energy.locked()); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_v2_normal/test.cpp b/test/test_v2_normal/test.cpp index 8ade351..0b51718 100644 --- a/test/test_v2_normal/test.cpp +++ b/test/test_v2_normal/test.cpp @@ -16,6 +16,7 @@ #include #include "v2/app.h" +#include "v2/stages/energy_stage.h" #include "v2/stages/normal_stage.h" #include "v2/stages/profile_picker_stage.h" @@ -358,6 +359,176 @@ TEST(NormalStage, OnEnterPdoBranchRendersVAReadoutAndPdoIndex) { conductor.start(); } +TEST(NormalView, LockedRendersPadlock) { + using ::testing::_; + NiceMock display; + + EXPECT_CALL( + display, + draw_xbm( + NormalView::PADLOCK_X, NormalView::PADLOCK_Y, + NormalView::PADLOCK_W, NormalView::PADLOCK_H, + bitmap::PADLOCK.data() + ) + ).Times(1); + + NormalViewModel vm{}; + vm.has_profile = true; + vm.is_pps = false; + vm.output_enabled = false; + vm.locked = true; + vm.active_pdo_index = 0; + + NormalView::render(display, vm); +} + +TEST(NormalView, UnlockedDoesNotDrawPadlock) { + using ::testing::_; + NiceMock display; + + EXPECT_CALL( + display, + draw_xbm(_, _, NormalView::PADLOCK_W, NormalView::PADLOCK_H, bitmap::PADLOCK.data()) + ).Times(0); + + NormalViewModel vm{}; + vm.has_profile = true; + vm.is_pps = false; + vm.output_enabled = false; + vm.locked = false; + vm.active_pdo_index = 0; + + NormalView::render(display, vm); +} + +TEST(NormalStage, ComboLongTogglesLock) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + + NormalStage normal(display, sink, gate); + TestConductor conductor; + conductor.register_stage(normal); + normal.prepare(0); + conductor.start(); + + EXPECT_FALSE(normal.locked()); + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + EXPECT_TRUE(normal.locked()); + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + EXPECT_FALSE(normal.locked()); +} + +TEST(NormalStage, LockedIgnoresRShort) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + EXPECT_CALL(gate, disable()).Times(::testing::AnyNumber()); + EXPECT_CALL(gate, enable()).Times(0); + + NormalStage normal(display, sink, gate); + TestConductor conductor; + conductor.register_stage(normal); + normal.prepare(0); + conductor.start(); + + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(normal.locked()); + + normal.on_event(conductor, ButtonEvent{ButtonId::R, Gesture::SHORT}, 0); +} + +TEST(NormalStage, LockedIgnoresEncoder) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(1)).WillRepeatedly(Return(true)); + EXPECT_CALL(sink, pdo_min_voltage_mv(1)).WillRepeatedly(Return(3300)); + EXPECT_CALL(sink, pdo_max_voltage_mv(1)).WillRepeatedly(Return(11000)); + EXPECT_CALL(sink, pdo_max_current_ma(1)).WillRepeatedly(Return(3000)); + EXPECT_CALL(sink, set_pps_pdo).WillRepeatedly(Return(true)); + + NormalStage normal(display, sink, gate); + TestConductor conductor; + conductor.register_stage(normal); + normal.prepare(1); + conductor.start(); + + const int32_t before = normal.target_mv(); + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + normal.on_event(conductor, EncoderEvent{2}, 0); + EXPECT_EQ(normal.target_mv(), before); +} + +TEST(NormalStage, OnEnterResetsLocked) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + + NormalStage normal(display, sink, gate); + TestConductor conductor; + conductor.register_stage(normal); + normal.prepare(0); + conductor.start(); + + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(normal.locked()); + + normal.prepare(0); + normal.on_enter(conductor); + EXPECT_FALSE(normal.locked()); +} + +TEST(NormalStage, LockedIgnoresLLong) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + + NormalStage normal(display, sink, gate); + ProfilePickerStage picker(display, sink); + TestConductor conductor; + conductor.register_stage(normal); + conductor.register_stage(picker); + normal.prepare(0); + conductor.start(); + + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(normal.locked()); + + normal.on_event(conductor, ButtonEvent{ButtonId::L, Gesture::LONG}, 0); + EXPECT_TRUE(normal.locked()); +} + +TEST(NormalStage, LockedIgnoresRLong) { + NiceMock display; + NiceMock sink; + NiceMock gate; + EXPECT_CALL(sink, is_index_pps(::testing::_)).WillRepeatedly(Return(false)); + EXPECT_CALL(sink, set_pdo).WillRepeatedly(Return(true)); + + NormalStage normal(display, sink, gate); + EnergyStage energy(display, gate); + TestConductor conductor; + conductor.register_stage(normal); + conductor.register_stage(energy); + normal.prepare(0); + conductor.start(); + + normal.on_event(conductor, ButtonEvent{ButtonId::L_R, Gesture::LONG}, 0); + ASSERT_TRUE(normal.locked()); + + normal.on_event(conductor, ButtonEvent{ButtonId::R, Gesture::LONG}, 0); + EXPECT_TRUE(normal.locked()); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();