M9 keys P1

This commit is contained in:
liquidraver
2026-07-21 15:55:51 +02:00
parent 47f24a0b1c
commit 16041b0f3a
12 changed files with 386 additions and 15 deletions
@@ -115,7 +115,14 @@ It is ZephCore's first ESP32-S3 + LR1110 board, so a fair amount of it is new gr
an existing port. Currently unverified: display init parameters (inherited from another ST7789V board — if
the screen is mirrored or upside down, that's a known one-line fix), GPS output from the CC1167Q module,
battery calibration, and whether the USB-C port reaches the native USB pads at all (BLE is the companion
path meanwhile). Not ported: the I2C keyboard, which is driven by a second MCU.
path meanwhile).
The M9 has no user button in hardware — its keypad is the only input — so it now runs the full companion UI
driven by a new driver for the STC8H keypad MCU on I2C. Enter, back, home, menu and page prev/next are wired
up, and typed characters pass straight through. **The arrow keys are not mapped yet**: no reference we could
find documents their codes, so rather than guess, the driver logs any key it doesn't recognise along with its
hex value. Press the arrows once with a serial console attached and they can be bound in a one-line change.
Until then you can move between pages but not up and down within a list.
Also worth knowing on this board specifically: the **SD slot shares the radio's SPI bus** with the LR1110
and the display. An inserted card was confirmed to break LR1110 radio-firmware flashing on this board — the
+6
View File
@@ -530,6 +530,12 @@ else()
message(STATUS "ZephCore Battery: generic LiPo curve")
endif()
# ========== I2C keypad (boards whose only input is a keyboard) ==========
if(CONFIG_INPUT)
target_sources_ifdef(CONFIG_ZEPHCORE_UI_DISPLAY app PRIVATE
helpers/input/stc8h_keypad.c)
endif()
# ========== Board-specific startup quirks ==========
# Optional per-board sd_quiesce.c — boards whose SD slot shares the radio's SPI
# bus put any inserted card into SPI idle before the radio driver initialises,
+3 -2
View File
@@ -859,8 +859,9 @@ config ZEPHCORE_UI_KEYBOARD
separate one. Selecting this makes ZEPHCORE_UI_DESIGN_JOYSTICK
available on a board that has no 5-way stick.
No board selects this yet: the STC8H driver and the keyboard text
entry screen are not written, and the M9 is unverified on hardware.
Selected by ThinkNode M9. The STC8H driver is helpers/input/
stc8h_keypad.c; the keypad's arrow-key codes are still unknown, so
the driver logs any code it does not recognise (see its header).
The key-space reservation in helpers/ui-joystick/joystick_defs.h is
the groundwork that makes them droppable in later.
@@ -1,3 +1,7 @@
config BOARD_THINKNODE_M9
select SOC_ESP32S3_WROOM_N16R8
select SOC_ESP32S3_PROCPU if BOARD_THINKNODE_M9_ESP32S3_PROCPU
# STC8H keypad on I2C1 @0x6C is this board's ONLY input — there is no
# user button in hardware. Selects the full companion UI, whose key
# space is built for a keyboard.
select ZEPHCORE_UI_KEYBOARD
+25 -12
View File
@@ -1,8 +1,8 @@
# Elecrow ThinkNode M9
ESP32-S3 handheld with LR1110 radio, ST7789 320x240 TFT, CC1167Q GPS, PCF8563
RTC, buzzer, STC8H I2C keyboard, QMI8658 IMU + QMC6309 magnetometer, and an SD
slot (keyboard/IMU/SD not ported — see below).
RTC, buzzer, STC8H I2C keypad, QMI8658 IMU + QMC6309 magnetometer, and an SD
slot (IMU/SD not ported — see below).
Ported 2026-07-18 from Arduino MeshCore `variants/thinknode_m9` (which itself
landed upstream only days earlier), cross-checked against a second,
@@ -90,10 +90,18 @@ in high flash are untouched by the updater flash.
LR1110 updater).
- **Charge/power sense** — GPIO1 = external-power detect, **active LOW**;
GPIO8 = charger DONE. Not consumed by ZephCore yet.
- **Keyboard identified** — STC8H helper MCU at **I2C 0x6C** on the second
bus (SDA=20 SCL=21), `KB_INT` GPIO12 idle-low/rising-edge, backlight
GPIO46. Registers: 0x01 battery, 0x03 long-press ms (16-bit write), 0x05
matrix key, 0x06 state.
- **Keypad PORTED** — STC8H helper MCU at **I2C 0x6C** on i2c1 (SDA=20
SCL=21), `KB_INT` GPIO12 idle-low/rising-edge, backlight GPIO46.
Registers: 0x01-0x04 battery mV little-endian, 0x05 pressed key, 0x06
state (write 0x01 = sleep). Driver: `helpers/input/stc8h_keypad.c`,
binding `zephcore,stc8h-keypad`. This is the board's ONLY input, so
`Kconfig.thinknode_m9` selects `ZEPHCORE_UI_KEYBOARD` -> the full
companion UI. **Arrow-key codes are still unknown** — no reference
documents them; the driver logs unrecognised codes at INFO so they can be
identified on hardware and added to `key_map[]`. Mapped so far: 0x0D
enter, 0x86 back, 0x82 home, 0x83 menu, 0x81/0x85 function keys ->
page prev/next, 0x87 and 0xA3 long-press variants. Printable ASCII
(0x20-0x7E) passes through untranslated.
- **Also on the sensor bus** — QMI8658 IMU + QMC6309 magnetometer.
Unported; no address collision with the PCF8563 @0x51.
@@ -142,21 +150,26 @@ firmware-sourced (both firmwares agree on all of them).
Still unverified on hardware, in rough priority order:
1. **Display init params** — gamma/porch/vcom inherited from Heltec T114's
1. **Keypad arrow codes** — the one blocker for a usable UI. Attach a serial
console, press each arrow key, and read the `unmapped keypad code 0x..`
lines; add them to `key_map[]` in `helpers/input/stc8h_keypad.c` as
`INPUT_KEY_UP/DOWN/LEFT/RIGHT`. Until then the UI changes pages but
cannot move within a list. Confirm `keypad at 0x6C ready` appears at boot.
2. **Display init params** — gamma/porch/vcom inherited from Heltec T114's
ST7789V (no board-specific init values exist in any reference); check
contrast/colors. If mirrored or upside down, swap `mdac` 0xA0 ↔ 0x60.
2. **GPS identity/output** — confirm NMEA at 115200 and that the CC1167Q
3. **GPS identity/output** — confirm NMEA at 115200 and that the CC1167Q
talks standard sentences (it should; both upstreams parse plain NMEA).
If a fix never arrives, probe `$PDTINFO` manually.
3. **Battery calibration** — the 2:1 divider is what both upstreams assume;
4. **Battery calibration** — the 2:1 divider is what both upstreams assume;
sanity-check `get bat` against a multimeter once, and check whether the
4.35 V charger variant actually charges above 4.20 V (OCV curve top).
4. **USB-OTG companion transport** — see the schematic section above:
5. **USB-OTG companion transport** — see the schematic section above:
verify whether the native USB pads reach the USB-C connector at all.
5. **rx-boosted** — deliberately ON (both upstreams leave boosted gain off /
6. **rx-boosted** — deliberately ON (both upstreams leave boosted gain off /
unset; every other ZephCore LR1110 board uses it). Verify RX sensitivity
and TX power on air.
6. **ADC2 vs WiFi** — battery reads ride ADC unit 2 (GPIO13), which WiFi also
7. **ADC2 vs WiFi** — battery reads ride ADC unit 2 (GPIO13), which WiFi also
uses; expect intermittent read failures in `wifi_ota.conf` builds.
## Not ported
@@ -20,6 +20,10 @@ CONFIG_ZEPHCORE_RADIO_LR1110=y
# Flash size — 16MB (override Zephyr default)
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
# The full companion UI is selected via ZEPHCORE_UI_KEYBOARD in
# Kconfig.thinknode_m9 (the STC8H keypad is this board's only input), so no
# UI_DESIGN override belongs here.
# PSRAM mode — WROOM-1-N16R8 is OPI octal.
# Enable is auto-detected from DTS by Kconfig.psram; only the mode is set here.
CONFIG_SPIRAM_MODE_OCT=y
@@ -210,6 +210,20 @@
};
};
/*
* STC8H keypad helper MCU — this board's only input device (no user button
* exists at hardware level). KB_INT on GPIO12 is idle-low and pulses high on
* a keypress; the driver also polls as a safety net.
*/
&i2c1 {
keypad: keypad@6c {
compatible = "zephcore,stc8h-keypad";
reg = <0x6c>;
irq-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>;
poll-interval-ms = <40>;
};
};
&i2c0 {
#include "../../common/sensors-i2c.dtsi"
/* ThinkNode M9 has a battery-backed PCF8563 @ 0x51 — opt in to boot-time
@@ -39,6 +39,18 @@
};
};
/* I2C1 — STC8H keypad helper MCU @0x6C. Separate bus from the RTC and
* sensors; the keypad is this board's only input device. */
i2c1_default: i2c1_default {
group1 {
pinmux = <I2C1_SDA_GPIO20>,
<I2C1_SCL_GPIO21>;
bias-pull-up;
drive-open-drain;
output-high;
};
};
/* UART1 — L76K GPS (GPIO2 = GPS TX → CPU RX, GPIO3 = GPS RX → CPU TX) */
uart1_default: uart1_default {
group1 {
@@ -67,6 +67,20 @@
pinctrl-names = "default";
};
/*
* I2C1 — STC8H keypad helper MCU @0x6C (GPIO20 SDA / GPIO21 SCL).
*
* This board has NO user button at hardware level, so the keypad is its only
* input device: without it the UI renders but cannot be operated. The keypad
* node itself lives in board.overlay alongside the rest of the app hookup.
*/
&i2c1 {
status = "okay";
clock-frequency = <I2C_BITRATE_STANDARD>;
pinctrl-0 = <&i2c1_default>;
pinctrl-names = "default";
};
&spi2 {
status = "okay";
#address-cells = <1>;
@@ -0,0 +1,47 @@
# Copyright 2026 ZephCore
# SPDX-License-Identifier: MIT
description: |
STC8H companion-MCU keypad (I2C).
A small helper MCU scans the key matrix and presents one register interface
over I2C. Used by the Elecrow ThinkNode M9, whose keypad is its only input
device — there is no user button on that board.
Protocol:
reg 0x01..0x04 battery millivolts, little-endian
reg 0x05 code of the key currently pressed (0 = none)
reg 0x06 state; writing 0x01 requests sleep
The MCU raises its interrupt line (idle-low, rising edge) when a key is
pressed. The driver reads register 0x05 on that edge and reports the result
through Zephyr's input subsystem. If no interrupt GPIO is supplied the
driver falls back to polling.
Example:
&i2c1 {
keypad: keypad@6c {
compatible = "zephcore,stc8h-keypad";
reg = <0x6c>;
irq-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>;
poll-interval-ms = <40>;
};
};
compatible: "zephcore,stc8h-keypad"
include: [i2c-device.yaml]
properties:
irq-gpios:
type: phandle-array
description: |
Key-press interrupt from the helper MCU. Idle-low, rising edge on press.
Optional — the driver polls when it is absent.
poll-interval-ms:
type: int
default: 40
description: |
Poll period. Used as the sole scan mechanism when irq-gpios is absent,
and as a safety net for a missed edge when it is present.
+232
View File
@@ -0,0 +1,232 @@
/*
* ZephCore — STC8H companion-MCU keypad driver
* Copyright (c) 2026 ZephCore
* SPDX-License-Identifier: MIT
*
* Reports keypresses from an I2C helper MCU through Zephyr's input subsystem.
* Written against the register interface documented in the binding; no vendor
* driver code is reused.
*
* Board context (ThinkNode M9): the keypad is the ONLY input device — that
* board has no user button at hardware level, confirmed against both upstream
* firmwares and the V1.0 schematic. Without this driver the UI renders but
* cannot be operated at all.
*
* Key codes emitted
* -----------------
* Printable ASCII (0x20-0x7E) is forwarded verbatim as INPUT_KEY_* would not
* express it; the joystick UI reserves exactly that range for typed characters
* (see helpers/ui-joystick/joystick_defs.h), so a character can travel to the
* UI untranslated. Everything else is a named key and is translated here.
*
* The named codes below are the ones confirmed from two independent firmwares
* for this keypad. Arrow keys are NOT among them, and no reachable reference
* documents them — so any code this driver does not recognise is reported at
* INFO level with its hex value. Press the arrow keys once on real hardware
* and the log names them; add them to key_map[] and navigation is complete.
* That is deliberate: guessing arrow codes would produce a driver that looks
* finished and silently does the wrong thing.
*/
#define DT_DRV_COMPAT zephcore_stc8h_keypad
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/input/input.h>
#include <zephyr/sys/util.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(stc8h_keypad, CONFIG_ZEPHCORE_LOG_LEVEL);
/* Register map — see dts/bindings/input/zephcore,stc8h-keypad.yaml */
#define STC8H_REG_BATTERY 0x01 /* 0x01..0x04, little-endian millivolts */
#define STC8H_REG_KEY 0x05
#define STC8H_REG_STATE 0x06
#define STC8H_STATE_SLEEP 0x01
#define STC8H_KEY_NONE 0x00
struct stc8h_config {
struct i2c_dt_spec i2c;
struct gpio_dt_spec irq;
uint32_t poll_interval_ms;
};
struct stc8h_data {
const struct device *dev;
struct gpio_callback irq_cb;
struct k_work_delayable work;
uint8_t last_key;
};
/*
* Named keys, as reported by the helper MCU.
*
* Only codes corroborated by two independent firmwares for this keypad are
* listed. The two function keys are mapped to page navigation because on a
* board with no other input they are the only way to move between screens;
* the UI treats them as prev/next.
*/
static const struct {
uint8_t raw;
uint16_t code;
} key_map[] = {
{ 0x0D, INPUT_KEY_ENTER }, /* enter / confirm */
{ 0x86, INPUT_KEY_ESC }, /* back */
{ 0x82, INPUT_KEY_HOME }, /* home */
{ 0x83, INPUT_KEY_MENU }, /* context menu */
{ 0x81, INPUT_KEY_PAGEUP }, /* left function key -> previous page */
{ 0x85, INPUT_KEY_PAGEDOWN },/* right function key -> next page */
{ 0x87, INPUT_KEY_F1 }, /* long-press map pin */
{ 0xA3, INPUT_KEY_F2 }, /* long-press enter */
};
static int stc8h_read_reg(const struct stc8h_config *cfg, uint8_t reg, uint8_t *val)
{
return i2c_write_read_dt(&cfg->i2c, &reg, 1, val, 1);
}
/* Battery millivolts as measured by the helper MCU. Not consumed yet — the
* board reads its own cell through the host ADC — but the registers exist and
* a second opinion is cheap to expose. */
int stc8h_keypad_battery_mv(const struct device *dev, uint16_t *mv)
{
const struct stc8h_config *cfg = dev->config;
uint8_t reg = STC8H_REG_BATTERY;
uint8_t buf[4];
int ret;
if (!mv) {
return -EINVAL;
}
ret = i2c_write_read_dt(&cfg->i2c, &reg, 1, buf, sizeof(buf));
if (ret < 0) {
return ret;
}
*mv = (uint16_t)(buf[0] | ((uint16_t)buf[1] << 8));
return 0;
}
int stc8h_keypad_sleep(const struct device *dev)
{
const struct stc8h_config *cfg = dev->config;
const uint8_t cmd[2] = { STC8H_REG_STATE, STC8H_STATE_SLEEP };
return i2c_write_dt(&cfg->i2c, cmd, sizeof(cmd));
}
static void stc8h_report(const struct device *dev, uint8_t raw)
{
/* Printable characters travel to the UI as themselves. */
if (raw >= 0x20 && raw <= 0x7E) {
input_report_key(dev, raw, 1, false, K_NO_WAIT);
input_report_key(dev, raw, 0, true, K_NO_WAIT);
return;
}
for (size_t i = 0; i < ARRAY_SIZE(key_map); i++) {
if (key_map[i].raw == raw) {
input_report_key(dev, key_map[i].code, 1, false, K_NO_WAIT);
input_report_key(dev, key_map[i].code, 0, true, K_NO_WAIT);
return;
}
}
/* Deliberately loud: this is how the missing arrow codes get identified.
* See the file header. */
LOG_INF("unmapped keypad code 0x%02X — add it to key_map[] to bind it", raw);
}
static void stc8h_work_handler(struct k_work *work)
{
struct k_work_delayable *dwork = k_work_delayable_from_work(work);
struct stc8h_data *data = CONTAINER_OF(dwork, struct stc8h_data, work);
const struct device *dev = data->dev;
const struct stc8h_config *cfg = dev->config;
uint8_t key = STC8H_KEY_NONE;
if (stc8h_read_reg(cfg, STC8H_REG_KEY, &key) == 0) {
/* Report on the transition only — the register reads the key
* that is *currently down*, so a held key would otherwise
* repeat at the poll rate. */
if (key != STC8H_KEY_NONE && key != data->last_key) {
stc8h_report(dev, key);
}
data->last_key = key;
}
k_work_reschedule(&data->work, K_MSEC(cfg->poll_interval_ms));
}
static void stc8h_irq_handler(const struct device *port, struct gpio_callback *cb,
uint32_t pins)
{
struct stc8h_data *data = CONTAINER_OF(cb, struct stc8h_data, irq_cb);
ARG_UNUSED(port);
ARG_UNUSED(pins);
/* I2C cannot be touched from an ISR — hand off to the work queue. */
k_work_reschedule(&data->work, K_NO_WAIT);
}
static int stc8h_init(const struct device *dev)
{
const struct stc8h_config *cfg = dev->config;
struct stc8h_data *data = dev->data;
uint8_t probe;
data->dev = dev;
data->last_key = STC8H_KEY_NONE;
if (!i2c_is_ready_dt(&cfg->i2c)) {
LOG_ERR("I2C bus not ready");
return -ENODEV;
}
/* A keypad that does not answer is not fatal — the node still boots,
* it just has no input. Say so plainly rather than failing init, which
* on this board would look like a dead device for a missing keypad. */
if (stc8h_read_reg(cfg, STC8H_REG_KEY, &probe) < 0) {
LOG_WRN("keypad not responding at 0x%02X — no input available",
cfg->i2c.addr);
return 0;
}
k_work_init_delayable(&data->work, stc8h_work_handler);
if (cfg->irq.port != NULL) {
if (!gpio_is_ready_dt(&cfg->irq)) {
LOG_ERR("IRQ GPIO not ready");
return -ENODEV;
}
gpio_pin_configure_dt(&cfg->irq, GPIO_INPUT);
gpio_init_callback(&data->irq_cb, stc8h_irq_handler,
BIT(cfg->irq.pin));
gpio_add_callback(cfg->irq.port, &data->irq_cb);
gpio_pin_interrupt_configure_dt(&cfg->irq, GPIO_INT_EDGE_TO_ACTIVE);
}
/* Poll regardless: it is the only mechanism without an IRQ line, and
* the safety net for a missed edge with one. */
k_work_reschedule(&data->work, K_MSEC(cfg->poll_interval_ms));
LOG_INF("keypad at 0x%02X ready (%s)", cfg->i2c.addr,
cfg->irq.port ? "interrupt + poll" : "poll only");
return 0;
}
#define STC8H_INIT(n) \
static const struct stc8h_config stc8h_cfg_##n = { \
.i2c = I2C_DT_SPEC_INST_GET(n), \
.irq = GPIO_DT_SPEC_INST_GET_OR(n, irq_gpios, { 0 }), \
.poll_interval_ms = DT_INST_PROP(n, poll_interval_ms), \
}; \
static struct stc8h_data stc8h_data_##n; \
DEVICE_DT_INST_DEFINE(n, stc8h_init, NULL, &stc8h_data_##n, \
&stc8h_cfg_##n, POST_KERNEL, \
CONFIG_INPUT_INIT_PRIORITY, NULL);
DT_INST_FOREACH_STATUS_OKAY(STC8H_INIT)
@@ -439,6 +439,17 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
/* All other keys: fire on press, ignore release */
if (!evt->value) return;
/* A keypad reports typed characters as their own ASCII code (see the
* key-space contract in joystick_defs.h: 0x20-0x7E is reserved for
* exactly this). Pass them through untranslated — the control codes
* below all live outside that range, so there is no ambiguity. */
if (evt->code >= 0x20 && evt->code <= 0x7E) {
if (joystick_queue_initialized) {
JoystickUITask::enqueueKey((char)evt->code);
}
return;
}
char key = 0;
switch (evt->code) {
case INPUT_KEY_LEFT: key = KEY_LEFT; break;
@@ -446,6 +457,12 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
case INPUT_KEY_BACK:
case INPUT_KEY_ESC: key = KEY_CANCEL; break;
case INPUT_KEY_1: key = KEY_CANCEL; break;
/* Keypad boards (ThinkNode M9) — the only input those have */
case INPUT_KEY_HOME: key = KEY_HOME; break;
case INPUT_KEY_MENU: key = KEY_SELECT; break;
case INPUT_KEY_PAGEUP: key = KEY_PREV; break;
case INPUT_KEY_PAGEDOWN:key = KEY_NEXT; break;
case INPUT_KEY_F2: key = KEY_ENTER_LONG; break;
/* Multi tap outputs from input_multi_tap filter */
case INPUT_KEY_D: key = KEY_BUZZ_TOGGLE; break; /* 3 taps */
case INPUT_KEY_C: key = KEY_GPS_TOGGLE; break; /* 4 taps */