diff --git a/epicardium/epicardium.h b/epicardium/epicardium.h
index 36ea64b818dc32c39b071a97305d36d5362b85bf..ce254cb6678c75c0af949dad1f748151b9b1d021 100644
--- a/epicardium/epicardium.h
+++ b/epicardium/epicardium.h
@@ -88,6 +88,8 @@ typedef _Bool bool;
 #define API_LIGHT_SENSOR_RUN       0x80
 #define API_LIGHT_SENSOR_GET       0x81
 #define API_LIGHT_SENSOR_STOP      0x82
+
+#define API_BUTTONS_READ           0x90
 /* clang-format on */
 
 typedef uint32_t api_int_id_t;
@@ -274,6 +276,68 @@ API_ISR(EPIC_INT_UART_RX, epic_isr_uart_rx);
  */
 API_ISR(EPIC_INT_CTRL_C, epic_isr_ctrl_c);
 
+/**
+ * Buttons
+ * =======
+ *
+ */
+
+/** Button IDs */
+enum epic_button {
+	/** ``1``, Bottom left button (bit 0). */
+	BUTTON_LEFT_BOTTOM   = 1,
+	/** ``2``, Bottom right button (bit 1). */
+	BUTTON_RIGHT_BOTTOM  = 2,
+	/** ``4``, Top right button (bit 2). */
+	BUTTON_RIGHT_TOP     = 4,
+	/** ``8``, Top left (power) button (bit 3). */
+	BUTTON_LEFT_TOP      = 8,
+	/** ``8``, Top left (power) button (bit 3). */
+	BUTTON_RESET         = 8,
+};
+
+/**
+ * Read buttons.
+ *
+ * :c:func:`epic_buttons_read` will read all buttons specified in ``mask`` and
+ * return set bits for each button which was reported as pressed.
+ *
+ * .. note::
+ *
+ *    The reset button cannot be unmapped from reset functionality.  So, while
+ *    you can read it, it cannot be used for app control.
+ *
+ * **Example**:
+ *
+ * .. code-block:: cpp
+ *
+ *    #include "epicardium.h"
+ *
+ *    uint8_t pressed = epic_buttons_read(BUTTON_LEFT_BOTTOM | BUTTON_RIGHT_BOTTOM);
+ *
+ *    if (pressed & BUTTON_LEFT_BOTTOM) {
+ *            // Bottom left button is pressed
+ *    }
+ *
+ *    if (pressed & BUTTON_RIGHT_BOTTOM) {
+ *            // Bottom right button is pressed
+ *    }
+ *
+ * :param uint8_t mask: Mask of buttons to read.  The 4 LSBs correspond to the 4
+ *     buttons:
+ *
+ *     ===== ========= ============ ===========
+ *     ``3`` ``2``     ``1``        ``0``
+ *     ----- --------- ------------ -----------
+ *     Reset Right Top Right Bottom Left Bottom
+ *     ===== ========= ============ ===========
+ *
+ *     Use the values defined in :c:type:`epic_button` for masking, as shown in
+ *     the example above.
+ * :return: Returns nonzero value if unmasked buttons are pushed.
+ */
+API(API_BUTTONS_READ, uint8_t epic_buttons_read(uint8_t mask));
+
 /**
  * LEDs
  * ====
diff --git a/epicardium/modules/buttons.c b/epicardium/modules/buttons.c
new file mode 100644
index 0000000000000000000000000000000000000000..e2cb20d39f053a8123a4f730986403dd3dd74493
--- /dev/null
+++ b/epicardium/modules/buttons.c
@@ -0,0 +1,32 @@
+#include "epicardium.h"
+
+#include "portexpander.h"
+#include "MAX77650-Arduino-Library.h"
+
+#include <stdint.h>
+
+static const uint8_t pin_mask[] = {
+	[BUTTON_LEFT_BOTTOM]  = 1 << 5,
+	[BUTTON_RIGHT_BOTTOM] = 1 << 3,
+	[BUTTON_RIGHT_TOP]    = 1 << 6,
+};
+
+uint8_t epic_buttons_read(uint8_t mask)
+{
+	uint8_t ret = 0;
+	if (portexpander_detected() && (mask & 0x3)) {
+		uint8_t pin_status = ~portexpander_get();
+
+		for (uint8_t m = 1; m < 0x8; m <<= 1) {
+			if (mask & m && pin_status & pin_mask[m]) {
+				ret |= m;
+			}
+		}
+	}
+
+	if (mask & BUTTON_RESET && MAX77650_getDebounceStatusnEN0()) {
+		ret |= BUTTON_RESET;
+	}
+
+	return ret;
+}
diff --git a/epicardium/modules/meson.build b/epicardium/modules/meson.build
index d7c9f40b90fe174bc81cba76cd928a210a4c2b75..3389a40911849b75cbd06ca8d0de7ebef785e601 100644
--- a/epicardium/modules/meson.build
+++ b/epicardium/modules/meson.build
@@ -1,4 +1,5 @@
 module_sources = files(
+  'buttons.c',
   'dispatcher.c',
   'display.c',
   'fileops.c',