diff --git a/include/v2/hal/display_orientation.h b/include/v2/hal/display_orientation.h new file mode 100644 index 0000000..d12b401 --- /dev/null +++ b/include/v2/hal/display_orientation.h @@ -0,0 +1,21 @@ +/** + * @file display_orientation.h + * @brief Abstract control over the panel mounting orientation. + * + */ +#pragma once + +namespace pocketpd { + + class DisplayOrientation { + public: + virtual ~DisplayOrientation() = default; + + /** + * @brief Rotate all subsequent rendering by 180 degrees (true) or restore the native + * orientation (false). Takes effect on the next flush. + */ + virtual void set_flipped(bool flipped) = 0; + }; + +} // namespace pocketpd diff --git a/include/v2/hal/eeprom.h b/include/v2/hal/eeprom.h index 71e6770..f675880 100644 --- a/include/v2/hal/eeprom.h +++ b/include/v2/hal/eeprom.h @@ -21,9 +21,10 @@ namespace pocketpd { struct Preferences { bool skip_picker_on_boot = false; bool voltage_comp_enabled = false; + bool flip_display = false; }; - static constexpr uint8_t PREFERENCES_LAYOUT_VERSION = 2; + static constexpr uint8_t PREFERENCES_LAYOUT_VERSION = 3; static constexpr size_t SIZE = sizeof(Preferences); static constexpr size_t EEPROM_PREFERENCES_BYTES = 1 + SIZE + 1; diff --git a/include/v2/hal/u8g2_display.h b/include/v2/hal/u8g2_display.h index 471f8f6..20e091d 100644 --- a/include/v2/hal/u8g2_display.h +++ b/include/v2/hal/u8g2_display.h @@ -11,9 +11,11 @@ #include "clib/u8g2.h" #include +#include "v2/hal/display_orientation.h" + namespace pocketpd { - class U8g2Display : public tempo::Display { + class U8g2Display : public tempo::Display, public DisplayOrientation { private: U8G2_SSD1306_128X64_NONAME_F_HW_I2C m_u8g2{U8G2_R0, U8X8_PIN_NONE}; @@ -71,6 +73,10 @@ namespace pocketpd { void draw_box(uint8_t x, uint8_t y, uint8_t w, uint8_t h) override { m_u8g2.drawBox(x, y, w, h); } + + void set_flipped(bool flipped) override { + m_u8g2.setDisplayRotation(flipped ? U8G2_R2 : U8G2_R0); + } }; } // namespace pocketpd diff --git a/include/v2/preferences_store.h b/include/v2/preferences_store.h index c57896e..a4a01d3 100644 --- a/include/v2/preferences_store.h +++ b/include/v2/preferences_store.h @@ -71,6 +71,19 @@ namespace pocketpd { m_preferences.voltage_comp_enabled = v; m_dirty = true; } + + bool flip_display() const { + return m_preferences.flip_display; + } + + void set_flip_display(bool v) { + if (m_preferences.flip_display == v) { + return; + } + + m_preferences.flip_display = v; + m_dirty = true; + } }; } // namespace pocketpd diff --git a/include/v2/stages/settings_stage.h b/include/v2/stages/settings_stage.h index f07d32b..bb44c3a 100644 --- a/include/v2/stages/settings_stage.h +++ b/include/v2/stages/settings_stage.h @@ -15,6 +15,7 @@ #include "v2/app.h" #include "v2/events.h" +#include "v2/hal/display_orientation.h" #include "v2/preferences_store.h" #include "v2/ui/table_view.h" @@ -27,6 +28,7 @@ namespace pocketpd { enum class Item : uint8_t { SKIP_PICKER, VOLTAGE_COMP, + FLIP_DISPLAY, }; struct SettingItem { @@ -34,12 +36,14 @@ namespace pocketpd { const char* label; }; - static constexpr std::array ITEMS = {{ + static constexpr std::array ITEMS = {{ {Item::SKIP_PICKER, "Skip picker"}, {Item::VOLTAGE_COMP, "Voltage comp"}, + {Item::FLIP_DISPLAY, "Flip display"}, }}; Display& m_display; + DisplayOrientation& m_orientation; PreferencesStore& m_prefs; TableView m_table{}; @@ -53,6 +57,8 @@ namespace pocketpd { return m_prefs.skip_picker_on_boot(); case Item::VOLTAGE_COMP: return m_prefs.voltage_comp_enabled(); + case Item::FLIP_DISPLAY: + return m_prefs.flip_display(); } return false; } @@ -65,6 +71,10 @@ namespace pocketpd { case Item::VOLTAGE_COMP: m_prefs.set_voltage_comp_enabled(!m_prefs.voltage_comp_enabled()); break; + case Item::FLIP_DISPLAY: + m_prefs.set_flip_display(!m_prefs.flip_display()); + m_orientation.set_flipped(m_prefs.flip_display()); + break; } draw(); @@ -88,8 +98,8 @@ namespace pocketpd { } public: - SettingsStage(Display& display, PreferencesStore& prefs) - : m_display(display), m_prefs(prefs) {} + SettingsStage(Display& display, DisplayOrientation& orientation, PreferencesStore& prefs) + : m_display(display), m_orientation(orientation), m_prefs(prefs) {} void on_enter(Conductor&, uint32_t) override { m_table.reset(); diff --git a/include/v2/tasks/button_task.h b/include/v2/tasks/button_task.h index 06b4781..a7ca788 100644 --- a/include/v2/tasks/button_task.h +++ b/include/v2/tasks/button_task.h @@ -15,6 +15,7 @@ #include "v2/input/button_gesture.h" #include "v2/input/two_buttons_gesture.h" #include "v2/pocketpd.h" +#include "v2/preferences_store.h" namespace pocketpd { @@ -32,12 +33,31 @@ namespace pocketpd { std::array m_detector_refs; TwoButtonsGestureDetector m_combo_detector; + const PreferencesStore& m_prefs; static constexpr uint32_t POLL_PERIOD_MS = 5; static constexpr size_t IDX_ENCODER = 0; static constexpr size_t IDX_L = 1; static constexpr size_t IDX_R = 2; + /** + * @brief Flipped display means the unit is held upside down, so the physical L button + * sits on the user's right. Swap L/R at publish time; ENCODER and L_R are symmetric. + */ + ButtonId published_id(ButtonId id) const { + if (!m_prefs.flip_display()) { + return id; + } + switch (id) { + case ButtonId::L: + return ButtonId::R; + case ButtonId::R: + return ButtonId::L; + default: + return id; + } + } + public: static constexpr const char* LOG_TAG = "ButtonTask"; @@ -45,6 +65,7 @@ namespace pocketpd { tempo::ButtonInput& btn_encoder, tempo::ButtonInput& btn_l, tempo::ButtonInput& btn_r, + const PreferencesStore& prefs, ButtonGestureConfig gesture_config = {} ) : App::BackgroundTask(POLL_PERIOD_MS), @@ -65,7 +86,8 @@ namespace pocketpd { ButtonGestureDetector{gesture_config}, }, }, - m_combo_detector(gesture_config) {} + m_combo_detector(gesture_config), + m_prefs(prefs) {} const char* name() const override { return "ButtonTask"; @@ -117,7 +139,7 @@ namespace pocketpd { const uint32_t duration = ref.detector.duration(now_ms); const char* msg = "button={} gesture={} hold_duration={}"; log.debug(msg, to_string(ref.id), to_string(gesture.value()), duration); - publish(ButtonEvent{ref.id, gesture.value()}); + publish(ButtonEvent{published_id(ref.id), gesture.value()}); } } diff --git a/src/main.cpp b/src/main.cpp index ac726ee..84ad849 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -68,11 +68,11 @@ namespace pocketpd { NormalStage normal_stage(u8g2_display, pd_sink, output_gate); EnergyStage energy_stage(u8g2_display, output_gate); MenuStage menu_stage(u8g2_display); - SettingsStage settings_stage(u8g2_display, prefs); + SettingsStage settings_stage(u8g2_display, u8g2_display, prefs); // —— Tasks - ButtonTask button_task(encoder_button, l_button, r_button); + ButtonTask button_task(encoder_button, l_button, r_button, prefs); EncoderTask encoder_task(encoder); SensorTask sensor_task{power_monitor, supply_voltage_source}; EnergyTask energy_task{output_gate}; @@ -100,6 +100,7 @@ void setup() { if (!prefs.load()) { Serial.println("[main] preferences load failed; defaults restored"); } + u8g2_display.set_flipped(prefs.flip_display()); encoder.begin(); app.register_stage(boot_stage); diff --git a/test/mocks/MockDisplayOrientation.h b/test/mocks/MockDisplayOrientation.h new file mode 100644 index 0000000..19887df --- /dev/null +++ b/test/mocks/MockDisplayOrientation.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "v2/hal/display_orientation.h" + +namespace pocketpd { + + class MockDisplayOrientation : public DisplayOrientation { + public: + MOCK_METHOD(void, set_flipped, (bool), (override)); + }; + + /** + * @brief Scripted DisplayOrientation for tests that want to read back the resulting state. + */ + class FakeDisplayOrientation : public DisplayOrientation { + private: + bool m_flipped = false; + int m_call_count = 0; + + public: + void set_flipped(bool flipped) override { + m_flipped = flipped; + ++m_call_count; + } + + bool flipped() const { + return m_flipped; + } + + int call_count() const { + return m_call_count; + } + }; + +} // namespace pocketpd diff --git a/test/test_v2_eeprom/test.cpp b/test/test_v2_eeprom/test.cpp index 0279db0..33104a8 100644 --- a/test/test_v2_eeprom/test.cpp +++ b/test/test_v2_eeprom/test.cpp @@ -67,6 +67,26 @@ TEST(EepromCodec, DefaultsHaveVoltageCompOff) { EXPECT_FALSE(p.voltage_comp_enabled); } +TEST(EepromCodec, FlipDisplayFieldRoundTrips) { + std::array buf{}; + + Preferences in{ + .skip_picker_on_boot = false, + .voltage_comp_enabled = false, + .flip_display = true, + }; + encode_preferences(in, buf.data()); + + Preferences out{}; + EXPECT_TRUE(decode_preferences(buf.data(), out)); + EXPECT_TRUE(out.flip_display); +} + +TEST(EepromCodec, DefaultsHaveFlipDisplayOff) { + Preferences p{}; + EXPECT_FALSE(p.flip_display); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_v2_inputs/test.cpp b/test/test_v2_inputs/test.cpp index 59103d2..0d7f52b 100644 --- a/test/test_v2_inputs/test.cpp +++ b/test/test_v2_inputs/test.cpp @@ -8,6 +8,7 @@ #define VERSION "\"test\"" #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "v2/input/button_gesture.h" #include "v2/input/two_buttons_gesture.h" #include "v2/pocketpd.h" +#include "v2/preferences_store.h" #include "v2/tasks/button_task.h" #include "v2/tasks/encoder_task.h" @@ -94,7 +96,9 @@ TEST(ButtonTask, ShortGestureOnQuickRelease) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); encoder.set_held(true); @@ -112,7 +116,9 @@ TEST(ButtonTask, LongGestureFiresWhileHeldAndSilencesRelease) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); encoder.set_held(true); @@ -135,7 +141,9 @@ TEST(ButtonTask, RButtonShortGestureRoutesToR) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); r.set_held(true); @@ -153,7 +161,9 @@ TEST(ButtonTask, LButtonLongPress) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); l.set_held(true); @@ -166,6 +176,68 @@ TEST(ButtonTask, LButtonLongPress) { EXPECT_EQ(btn->gesture, Gesture::LONG); } +// —— ButtonTask flipped display + +TEST(ButtonTask, FlipDisplaySwapsPublishedLR) { + FakeButtonInput encoder, l, r; + TestQueue q; + TestPublisher pub(q); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); + task.attach_publisher_INTERNAL_DO_NOT_USE(pub); + + prefs.set_flip_display(true); + + l.set_held(true); + task.poll(0); + l.set_held(false); + task.poll(50); + + const auto* btn = pop_as(q); + ASSERT_NE(btn, nullptr); + EXPECT_EQ(btn->id, ButtonId::R); + + r.set_held(true); + task.poll(200); + r.set_held(false); + task.poll(250); + + btn = pop_as(q); + ASSERT_NE(btn, nullptr); + EXPECT_EQ(btn->id, ButtonId::L); +} + +TEST(ButtonTask, FlipDisplayLeavesEncoderAndComboAlone) { + FakeButtonInput encoder, l, r; + TestQueue q; + TestPublisher pub(q); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); + task.attach_publisher_INTERNAL_DO_NOT_USE(pub); + + prefs.set_flip_display(true); + + encoder.set_held(true); + task.poll(0); + encoder.set_held(false); + task.poll(50); + + const auto* btn = pop_as(q); + ASSERT_NE(btn, nullptr); + EXPECT_EQ(btn->id, ButtonId::ENCODER); + + l.set_held(true); + r.set_held(true); + task.poll(200); + task.poll(200 + kDefaultCfg.long_press_ms); + + btn = pop_as(q); + ASSERT_NE(btn, nullptr); + EXPECT_EQ(btn->id, ButtonId::L_R); +} + // —— EncoderTask TEST(EncoderTask, OnStartLatchesBaselineWithoutEvent) { @@ -234,7 +306,9 @@ TEST(ButtonTask, BriefSimultaneousTapDropsBothShorts) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); l.set_held(true); @@ -253,7 +327,9 @@ TEST(ButtonTask, ComboLongAtThresholdEmitsLRSuppressesIndividuals) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); l.set_held(true); @@ -287,7 +363,9 @@ TEST(ButtonTask, AbortedComboCancelsRemainingSingles) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); l.set_held(true); @@ -313,7 +391,9 @@ TEST(ButtonTask, EncoderButtonNotSuppressedByCombo) { FakeButtonInput encoder, l, r; TestQueue q; TestPublisher pub(q); - ButtonTask task(encoder, l, r); + ::testing::NiceMock eeprom; + PreferencesStore prefs{eeprom}; + ButtonTask task(encoder, l, r, prefs); task.attach_publisher_INTERNAL_DO_NOT_USE(pub); l.set_held(true); diff --git a/test/test_v2_menu/test.cpp b/test/test_v2_menu/test.cpp index 0784ba1..948f3d9 100644 --- a/test/test_v2_menu/test.cpp +++ b/test/test_v2_menu/test.cpp @@ -1,6 +1,7 @@ #define VERSION "\"test\"" #include +#include #include #include #include @@ -28,10 +29,11 @@ namespace { NiceMock sink; NiceMock gate; NiceMock eeprom; + FakeDisplayOrientation orientation; PreferencesStore prefs{eeprom}; MenuStage menu{display}; ProfilePickerStage picker{display, sink}; - SettingsStage settings_stage{display, prefs}; + SettingsStage settings_stage{display, orientation, prefs}; NormalStage normal{display, sink, gate}; TestConductor conductor; diff --git a/test/test_v2_settings/test.cpp b/test/test_v2_settings/test.cpp index 17826ff..5424ec0 100644 --- a/test/test_v2_settings/test.cpp +++ b/test/test_v2_settings/test.cpp @@ -1,6 +1,7 @@ #define VERSION "\"test\"" #include +#include #include #include #include @@ -28,8 +29,9 @@ namespace { NiceMock sink; NiceMock gate; NiceMock eeprom; + FakeDisplayOrientation orientation; PreferencesStore prefs{eeprom}; - SettingsStage stage{display, prefs}; + SettingsStage stage{display, orientation, prefs}; MenuStage menu{display}; ProfilePickerStage picker{display, sink}; NormalStage normal{display, sink, gate}; @@ -171,6 +173,50 @@ TEST(SettingsStage, ExitFlushesVoltageCompToggle) { EXPECT_TRUE(h.conductor.apply_pending_transition(0)); } +TEST(SettingsStage, RendersFlipDisplayRowBelowVoltageComp) { + Harness h; + EXPECT_CALL(h.display, draw_text(_, _, _)).Times(::testing::AnyNumber()); + EXPECT_CALL(h.display, draw_text(10, 36, StrEq("[ ] Flip display"))).Times(1); + h.conductor.start(0); +} + +TEST(SettingsStage, EncoderLongOnFlipDisplayTogglesPreferenceAndApplies) { + Harness h; + h.conductor.start(0); + + h.stage.on_event(h.conductor, EncoderEvent{1}, 0); + h.stage.on_event(h.conductor, EncoderEvent{1}, 0); + h.stage.on_event(h.conductor, ButtonEvent{ButtonId::ENCODER, Gesture::LONG}, 0); + + EXPECT_TRUE(h.prefs.flip_display()); + EXPECT_TRUE(h.prefs.dirty()); + EXPECT_TRUE(h.orientation.flipped()); + EXPECT_EQ(h.orientation.call_count(), 1); +} + +TEST(SettingsStage, FlipDisplayToggleTwiceRestoresOrientation) { + Harness h; + h.conductor.start(0); + + h.stage.on_event(h.conductor, EncoderEvent{1}, 0); + h.stage.on_event(h.conductor, EncoderEvent{1}, 0); + h.stage.on_event(h.conductor, ButtonEvent{ButtonId::ENCODER, Gesture::LONG}, 0); + h.stage.on_event(h.conductor, ButtonEvent{ButtonId::ENCODER, Gesture::LONG}, 0); + + EXPECT_FALSE(h.prefs.flip_display()); + EXPECT_FALSE(h.orientation.flipped()); + EXPECT_EQ(h.orientation.call_count(), 2); +} + +TEST(SettingsStage, TogglingOtherItemsDoesNotTouchOrientation) { + Harness h; + h.conductor.start(0); + + h.stage.on_event(h.conductor, ButtonEvent{ButtonId::ENCODER, Gesture::LONG}, 0); + + EXPECT_EQ(h.orientation.call_count(), 0); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();