diff --git a/python_payload/st3m/about.py b/python_payload/st3m/about.py
index 3f0b03bbe0931db07a7da44db7a0dbe4ebf8d69d..c2058bcf1172ccb32e3b27671e6e8854504c8ecb 100644
--- a/python_payload/st3m/about.py
+++ b/python_payload/st3m/about.py
@@ -101,14 +101,10 @@ class About(BaseView):
         super().think(ins, delta_ms)
         self.ts += delta_ms / 1000
 
-        if self.input.left_shoulder.middle.pressed:
-            if self.vm is not None:
-                self.vm.pop(ViewTransitionSwipeRight())
-
         # Change target screen intent.
-        if self.input.left_shoulder.left.pressed and self._can_left():
+        if self.input.buttons.app.left.pressed and self._can_left():
             self.screen_ix -= 1
-        if self.input.left_shoulder.right.pressed and self._can_right():
+        if self.input.buttons.app.right.pressed and self._can_right():
             self.screen_ix += 1
 
         # Calculate animation/transitions.
diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py
index fc3b515d093a0e0d0c3e532792ea5d22c7c4a458..673890cc91a5388601c76ae776de7cee9c75652f 100644
--- a/python_payload/st3m/application.py
+++ b/python_payload/st3m/application.py
@@ -43,7 +43,7 @@ class Application(BaseView):
     def think(self, ins: InputState, delta_ms: int) -> None:
         super().think(ins, delta_ms)
 
-        if self.input.left_shoulder.middle.pressed:
+        if self.input.buttons.os.middle.pressed:
             if self.vm is not None:
                 self.on_exit()
                 self.vm.pop(ViewTransitionSwipeRight())
diff --git a/python_payload/st3m/input.py b/python_payload/st3m/input.py
index ea420b9a0a6e6a6ace8dc45fceed6a9e0be4f990..f491c95fc86753291260d0cdc8ba8eee0a1db6ae 100644
--- a/python_payload/st3m/input.py
+++ b/python_payload/st3m/input.py
@@ -28,6 +28,44 @@ class IMUState:
         self.pressure = pressure
 
 
+class InputButtonState:
+    """
+    State of the tri-state switches/buttons on the shoulders of the badge.
+
+    If you want to detect edges, use the stateful InputController.
+
+    By default, the left shoulder button is the 'app' button and the right
+    shoulder button is the 'os' button. The user can switch this behaviour in
+    the settings menu.
+
+    The 'app' button can be freely used by applicaton code. The 'os' menu has
+    fixed functions: volume up/down and back.
+
+    In cases you want to access left/right buttons independently of app/os
+    mapping (for example in applications where the handedness of the user
+    doesn't matter), then you can use _left and _right to access their state
+    directly.
+
+    'app_is_left' is provided to let you figure out on which side of the badge
+    the app button is, eg. for use when highlighting buttons on the screen or
+    with LEDs.
+    """
+
+    __slots__ = ("app", "os", "_left", "_right", "app_is_left")
+
+    def __init__(self, left: int, right: int, swapped: bool):
+        app = left
+        os = right
+        if swapped:
+            app, os = os, app
+
+        self.app = app
+        self.os = os
+        self._left = left
+        self._right = right
+        self.app_is_left = not swapped
+
+
 class InputState:
     """
     Current state of inputs from badge user. Passed via think() to every
@@ -39,16 +77,14 @@ class InputState:
     def __init__(
         self,
         captouch: captouch.CaptouchState,
-        left_button: int,
-        right_button: int,
+        buttons: InputButtonState,
         imu: IMUState,
         temperature: float,
         battery_voltage: float,
     ) -> None:
         # self.petal_pads = petal_pads
         self.captouch = captouch
-        self.left_button = left_button
-        self.right_button = right_button
+        self.buttons = buttons
         self.imu = imu
         self.temperature = temperature
         self.battery_voltage = battery_voltage
@@ -60,10 +96,9 @@ class InputState:
         Reactor.
         """
         cts = captouch.read()
-        left_button = hardware.left_button_get()
-        right_button = hardware.right_button_get()
-        if swapped_buttons:
-            left_button, right_button = right_button, left_button
+        left = hardware.left_button_get()
+        right = hardware.right_button_get()
+        buttons = InputButtonState(left, right, swapped_buttons)
 
         acc = imu.acc_read()
         gyro = imu.gyro_read()
@@ -73,8 +108,7 @@ class InputState:
         battery_voltage = power.battery_voltage
         return InputState(
             cts,
-            left_button,
-            right_button,
+            buttons,
             imu_state,
             temperature,
             battery_voltage,
@@ -461,35 +495,19 @@ class CaptouchState:
             petal.whole._ignore_pressed()
 
 
-class TriSwitchHandedness(Enum):
-    """
-    Left or right shoulder button.
-    """
-
-    left = "left"
-    right = "right"
-
-
 class TriSwitchState:
     """
     State of a tri-stat shoulder button
     """
 
-    __slots__ = ("left", "middle", "right", "handedness")
-
-    def __init__(self, h: TriSwitchHandedness) -> None:
-        self.handedness = h
+    __slots__ = ("left", "middle", "right")
 
+    def __init__(self) -> None:
         self.left = Pressable(False)
         self.middle = Pressable(False)
         self.right = Pressable(False)
 
-    def _update(self, ts: int, hr: InputState) -> None:
-        st = (
-            hr.left_button
-            if self.handedness == TriSwitchHandedness.left
-            else hr.right_button
-        )
+    def _update(self, ts: int, st: int) -> None:
         self.left._update(ts, st == -1)
         self.middle._update(ts, st == 2)
         self.right._update(ts, st == 1)
@@ -500,6 +518,42 @@ class TriSwitchState:
         self.right._ignore_pressed()
 
 
+class ButtonsState:
+    """
+    Edge-trigger detection for input button state.
+
+    See  InputButtonState for more information about the meaning of app, os,
+    _left, _right and app_is_left.
+    """
+
+    __slots__ = ("app", "os", "_left", "_right", "app_is_left")
+
+    def __init__(self) -> None:
+        self.app = TriSwitchState()
+        self.os = TriSwitchState()
+
+        # Defaults. Real data coming from _update will change this to the
+        # correct values from an InputState.
+        self._left = self.app
+        self._right = self.os
+        self.app_is_left = True
+
+    def _update(self, ts: int, hr: InputState) -> None:
+        self.app._update(ts, hr.buttons.app)
+        self.os._update(ts, hr.buttons.os)
+        self.app_is_left = hr.buttons.app_is_left
+        if self.app_is_left:
+            self._left = self.app
+            self._right = self.os
+        else:
+            self._left = self.os
+            self._right = self.app
+
+    def _ignore_pressed(self) -> None:
+        self.app._ignore_pressed()
+        self.os._ignore_pressed()
+
+
 class InputController:
     """
     A stateful input controller. It accepts InputState updates from the Reactor
@@ -514,22 +568,19 @@ class InputController:
 
     __slots__ = (
         "captouch",
-        "left_shoulder",
-        "right_shoulder",
+        "buttons",
         "_ts",
     )
 
     def __init__(self) -> None:
         self.captouch = CaptouchState()
-        self.left_shoulder = TriSwitchState(TriSwitchHandedness.left)
-        self.right_shoulder = TriSwitchState(TriSwitchHandedness.right)
+        self.buttons = ButtonsState()
         self._ts = 0
 
     def think(self, hr: InputState, delta_ms: int) -> None:
         self._ts += delta_ms
         self.captouch._update(self._ts, hr)
-        self.left_shoulder._update(self._ts, hr)
-        self.right_shoulder._update(self._ts, hr)
+        self.buttons._update(self._ts, hr)
 
     def _ignore_pressed(self) -> None:
         """
@@ -538,5 +589,4 @@ class InputController:
         have just been foregrounded.
         """
         self.captouch._ignore_pressed()
-        self.left_shoulder._ignore_pressed()
-        self.right_shoulder._ignore_pressed()
+        self.buttons._ignore_pressed()
diff --git a/python_payload/st3m/processors.py b/python_payload/st3m/processors.py
index 787a18fa17c8b9afd2136bf2fad57e5d7a288e2c..6f7e78b40f27e762274368f48d99005505b6fb08 100644
--- a/python_payload/st3m/processors.py
+++ b/python_payload/st3m/processors.py
@@ -37,13 +37,13 @@ class AudioProcessor(Processor):
         adjusted = False
         # Whether the volume is so low that we should enable mute.
         should_mute = False
-        if self.input.right_shoulder.left.pressed:
+        if self.input.buttons.os.left.pressed:
             started_at = audio.get_volume_dB()
             if started_at <= -20:
                 should_mute = True
             audio.adjust_volume_dB(-5)
             adjusted = True
-        if self.input.right_shoulder.right.pressed:
+        if self.input.buttons.os.right.pressed:
             if not audio.get_mute():
                 audio.adjust_volume_dB(5)
             adjusted = True
diff --git a/python_payload/st3m/ui/menu.py b/python_payload/st3m/ui/menu.py
index 36ec747e2c4c62022a866a155df02e04b724f163..66c82ca9db515cdc2ace886b42cbdd94ec285093 100644
--- a/python_payload/st3m/ui/menu.py
+++ b/python_payload/st3m/ui/menu.py
@@ -172,8 +172,8 @@ class MenuController(BaseView):
         super().on_enter(vm)
 
     def _parse_state(self) -> None:
-        left = self.input.left_shoulder.left
-        right = self.input.left_shoulder.right
+        left = self.input.buttons.app.left
+        right = self.input.buttons.app.right
 
         if left.pressed:
             self._scroll_controller.scroll_left()
@@ -197,7 +197,7 @@ class MenuController(BaseView):
 
         self._scroll_controller.think(ins, delta_ms)
 
-        if self.input.left_shoulder.middle.pressed:
+        if self.input.buttons.app.middle.pressed:
             self.select()
         self._parse_state()