display/input rotate

This commit is contained in:
liquidraver
2026-08-25 15:19:11 +02:00
parent 4632797e3e
commit 2e3f5f3f59
20 changed files with 544 additions and 19 deletions
+4
View File
@@ -262,6 +262,8 @@ All `set uplink.*` changes are saved immediately and only applied after reboot.
| `get radio.rxgain` | RX gain boost: `on` or `off` |
| `get radio.fem.rxgain` | External FEM's LNA in the RX path: `on` (through the LNA) or `off` (bypassed). Default `on` |
| `get rxduty` | RX duty cycle mode: `0` or `1` |
| `get display.rotate` | Panel 180-degree rotation: `0` or `1`. Reports the **live panel state**, not the stored byte — the two differ only when a rotation was refused, which is the case worth seeing. Boards whose panel cannot rotate reply `unsupported (panel cannot rotate)` |
| `get input.rotate` | Joystick/D-pad axis swap: `0` or `1` |
| `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) |
| `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference |
| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. |
@@ -314,6 +316,8 @@ Changes are persisted immediately unless noted. Some require a reboot.
| `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. |
| `set radio.fem.rxgain <0\|1\|on\|off>` | default **1** | Routes receive through the external FEM's LNA (`1`) or around it via the FEM's bypass path (`0`), applied live. Sensitivity for battery life — `0` costs roughly 17 dB and saves the LNA's supply current. Transmit, and the driver's idle/sleep gating of the FEM, are unaffected either way. Supported only where the FEM's receive path is software-selectable and that select line is wired to the radio node as `lna-bypass-gpios` — today the three KCT8103L boards, `heltec_t096`, `heltec_wireless_tracker_v2` and `heltec_wifi_lora32_v43`. Every other board reports `Error: unsupported`: `heltec_wifi_lora32_v4`'s GC1109 has no receive-path select (its CPS is don't-care in RX, same as MeshCore); `station_g2`, `gat562_30s`, `ikoka_nano_30dbm` and `promicro_sx1262` have only the DIO2/TXEN/RXEN transmit-receive switch; `rak3401_1watt`'s SKY66122 is enabled by a standalone always-on regulator outside the radio node; and non-SX126x radios (LR1110, LR2021, SX127x) never implement it. The pref is still saved when unsupported. **Do not expect the FEM's chip-enable to be the knob** — deasserting `antenna-enable-gpios` in RX shuts the part down and takes the through path with it (~69 dB measured on a V4.3), which is what 1.17.2 did before this moved to `lna-bypass-gpios`. |
| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. |
| `set display.rotate <0\|1\|on\|off>` | default **0** | Rotate the display 180 degrees, for cases and upgrade kits that mount the screen upside down (e.g. the Meshnology N37E for the Wio Tracker L1). Applied live — the driver flips the panel's `SEGMENT_MAP` and `COM_OUTPUT_SCAN`, two bytes on the wire, and the next frame comes out rotated with no redraw and no per-frame cost. **Only full-height SSD1306 and SH1106 panels support this** (`rak4631`, `gat562_30s`, `heltec_wifi_lora32_v4`/`v43`, `lilygo_t3s3`, `station_g2`, `wio_tracker_l1`); every other panel replies `Error: this panel cannot rotate` and the pref is **not** saved, so a stored value can never disagree with what the screen shows. `lilygo_timpulse_plus` is excluded despite being an SSD1306: its 64x32 glass is windowed into a 128x64 controller at `page-offset 4`, and the COM-scan reversal flips the controller's whole range, which would move the image off the bonded region. E-paper (SSD16xx) is excluded on purpose: its driver accepts a 180-degree orientation but implements it by flipping the RAM entry mode only, which reverses byte order without reversing bit order inside each byte — it would report success and render wrong. |
| `set input.rotate <0\|1\|on\|off>` | default **0** | Swap the joystick/D-pad axes — up/down and left/right — to match an upside-down mount. Applied live. Deliberately **separate** from `display.rotate`: a case can flip the screen without moving the stick, and boards whose panel cannot rotate can still need the axis swap. Works on every board with directional input, in both the joystick UI and the button UI (where it swaps page-prev/page-next). Non-directional keys, tap codes and long-press gestures are unaffected. |
| `set adc.multiplier <mult>` | `0` (use board default) or 10030000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. |
| `set adc.multiplier target <mv>` | 30004400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads `<mv>`. Replies with the old and new multiplier plus the before/after reading. `Error: no ADC reading on this board` if the board has no battery ADC. |
| `set adc.multiplier full` | board must be fully charged | Same calibration, but against the board's battery-curve 100% point instead of a hand-measured value. Only meaningful on a full charge. |
@@ -893,6 +893,16 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs)
prefs.fem_rxgain = buf[off++];
}
/* Offset 168-169: display_rotate / input_rotate (ZephCore extension).
* Absent in pre-existing files → both stay at the initNodePrefs() default
* of 0, i.e. the stock mounting orientation every deployed node runs. */
if (off < len) {
prefs.display_rotate = buf[off++];
}
if (off < len) {
prefs.input_rotate = buf[off++];
}
sanitizeNodePrefs(&prefs);
}
@@ -995,7 +1005,10 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
buf[off++] = prefs.v_contact_flags;
/* Offset 167: fem_rxgain (ZephCore extension, external FEM LNA in RX) */
buf[off++] = prefs.fem_rxgain;
/* Total: 168 bytes */
/* Offset 168-169: display_rotate / input_rotate (ZephCore extension) */
buf[off++] = prefs.display_rotate;
buf[off++] = prefs.input_rotate;
/* Total: 170 bytes */
bool ok = atomicReplaceFile(PREFS_FILE, buf, off);
LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'",
+8
View File
@@ -240,6 +240,11 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) {
* EOF read keeps the initNodePrefs() default fem_rxgain=1, which is what
* every already-deployed node has been running. */
fs_read(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain));
/* Mounting orientation, offsets 305-306. Absent in <307-byte files; the
* no-op EOF reads keep the initNodePrefs() defaults of 0/0, which is the
* stock orientation every already-deployed node runs. */
fs_read(&file, &prefs.display_rotate, sizeof(prefs.display_rotate));
fs_read(&file, &prefs.input_rotate, sizeof(prefs.input_rotate));
fs_close(&file);
@@ -385,6 +390,9 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) {
fs_write(&file, prefs.extra_sf, sizeof(prefs.extra_sf));
/* External FEM RX gain (offset 304) */
fs_write(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain));
/* Mounting orientation (offsets 305-306) */
fs_write(&file, &prefs.display_rotate, sizeof(prefs.display_rotate));
fs_write(&file, &prefs.input_rotate, sizeof(prefs.input_rotate));
ret = fs_sync(&file);
fs_close(&file);
+61
View File
@@ -8,6 +8,9 @@
#include "led_gate.h"
#include "buzzer_gate.h"
#include <helpers/ui/ui_task.h>
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
#include <helpers/ui/display.h>
#endif
#include <helpers/MeshTimeSync.h>
#include <helpers/time_sync.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
@@ -417,6 +420,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
}
} else if (memcmp(config, "rxduty", 6) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle);
} else if (memcmp(config, "display.rotate", 14) == 0) {
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) && MC_DISPLAY_ROTATE_SUPPORTED
// Report the live panel state, not the stored byte: they only
// differ if a rotation was refused, and that is exactly the case
// worth seeing.
snprintf(reply, CLI_REPLY_SIZE, "> %d", mc_display_is_rotated() ? 1 : 0);
#else
strcpy(reply, "> unsupported (panel cannot rotate)");
#endif
} else if (memcmp(config, "input.rotate", 12) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", zephcore_input_is_flipped() ? 1 : 0);
} else if (memcmp(config, "gps diag", 8) == 0) {
// What the last module-configuration attempt actually did.
reply[0] = '>'; reply[1] = ' ';
@@ -1010,6 +1024,53 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
} else {
strcpy(reply, "Error: must be 0, 1, on, or off");
}
} else if (memcmp(config, "display.rotate ", 15) == 0) {
// Rotate the panel 180 degrees for cases that mount it upside down.
// Applied immediately -- the SSD1306/SH1106 remap is two bytes on
// the wire and the next frame comes out flipped, no redraw needed.
const char* arg = &config[15];
int val = -1;
if (memcmp(arg, "on", 2) == 0) val = 1;
else if (memcmp(arg, "off", 3) == 0) val = 0;
else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg);
if (val != 0 && val != 1) {
strcpy(reply, "Error: must be 0, 1, on, or off");
} else {
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
int ret = mc_display_set_rotated(val == 1);
if (ret == 0) {
// Persist only what the panel actually did, so a stored
// value can never disagree with what the screen shows.
_prefs->display_rotate = (uint8_t)val;
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - display.rotate=%d", val);
} else if (ret == -ENOTSUP) {
strcpy(reply, "Error: this panel cannot rotate");
} else {
snprintf(reply, CLI_REPLY_SIZE, "Error: rotate failed (%d)", ret);
}
#else
strcpy(reply, "Error: no display");
#endif
}
} else if (memcmp(config, "input.rotate ", 13) == 0) {
// Swap the joystick/D-pad axes to match an upside-down mount.
// Deliberately separate from display.rotate: a case can flip the
// screen without flipping the stick, and boards whose panel cannot
// rotate can still need the axis swap.
const char* arg = &config[13];
int val = -1;
if (memcmp(arg, "on", 2) == 0) val = 1;
else if (memcmp(arg, "off", 3) == 0) val = 0;
else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg);
if (val == 0 || val == 1) {
zephcore_input_set_flipped(val == 1);
_prefs->input_rotate = (uint8_t)val;
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - input.rotate=%d", val);
} else {
strcpy(reply, "Error: must be 0, 1, on, or off");
}
} else if (memcmp(config, "gps diag", 8) == 0) {
// set gps diag <0|1|on|off> — arm module-configuration reporting.
// Not persisted: clears on reboot, by design.
+17
View File
@@ -120,6 +120,19 @@ struct NodePrefs {
* driver rejects anything else. Ignored on non-LR2021 radios. */
uint8_t extra_sf[EXTRA_SF_MAX];
/* Physical mounting orientation. Common to both roles: a repeater board
* with an OLED (RAK4631, Heltec) can be mounted upside down just as a
* companion can, and the joystick boards ship a repeater artifact too.
*
* display_rotate rotates the panel 180 degrees in hardware and is only
* honoured on SSD1306/SH1106 (see MC_DISPLAY_ROTATE_SUPPORTED); other
* panels report it unsupported rather than silently ignoring it.
* input_rotate swaps the joystick/D-pad axes to match, and is kept
* separate because the two are not always wanted together a screen can
* be remounted without moving the stick. */
uint8_t display_rotate; // 1 = panel rotated 180 degrees
uint8_t input_rotate; // 1 = joystick up/down and left/right swapped
/* ---- Companion-only fields ---- */
uint8_t manual_add_contacts;
uint8_t telemetry_mode_base;
@@ -216,6 +229,8 @@ static inline void sanitizeNodePrefs(NodePrefs* p) {
p->cad_auto = saneBool<uint8_t>(p->cad_auto, 0);
p->allow_read_only = saneBool<uint8_t>(p->allow_read_only, 0);
p->powersaving_enabled = saneBool<uint8_t>(p->powersaving_enabled, 0);
p->display_rotate = saneBool<uint8_t>(p->display_rotate, 0);
p->input_rotate = saneBool<uint8_t>(p->input_rotate, 0);
/* Defaults that are on, not off. */
p->rx_boost = saneBool<uint8_t>(p->rx_boost, 1);
p->fem_rxgain = saneBool<uint8_t>(p->fem_rxgain, 1);
@@ -316,6 +331,8 @@ static inline void initNodePrefs(NodePrefs* prefs) {
prefs->probe_interval = 15; // floor sample + CAD probe; staircase responds in ~1-2 h
prefs->cad_busycap = 25; // back off detPeak once >25% of TX attempts are deferred
prefs->wake_on_msg = 1; // Default ON — wake display when message arrives
prefs->display_rotate = 0; // Default OFF — panel in the stock case orientation
prefs->input_rotate = 0; // Default OFF — joystick axes as the board wires them
prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion)
prefs->v_battery_alert_mv = 0xFFFF; // Sentinel: derive from board auto-shutdown threshold
}
+13 -6
View File
@@ -605,11 +605,18 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
return;
}
/* Joystick axis flip for upside-down mounts. Mapped into a local rather
* than written back into the event: the input event is shared with every
* other INPUT_CALLBACK_DEFINE consumer on the bus, and this remap is a
* UI-layer convention, not a hardware fact. Non-directional codes pass
* through untouched, so the tap-code and longpress paths are unaffected. */
const uint16_t code = zephcore_input_map_code(evt->code);
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
/* When Doom is running, intercept ALL input (presses AND releases) */
if (doom_game_is_running()) {
/* Double-click ENTER to exit: detect two presses within 500ms */
if (evt->code == INPUT_KEY_ENTER && evt->value) {
if (code == INPUT_KEY_ENTER && evt->value) {
static uint32_t doom_last_enter;
uint32_t now = k_uptime_get_32();
@@ -625,7 +632,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
}
/* Forward all key events (press + release) to Doom */
doom_game_input(evt->code, evt->value);
doom_game_input(code, evt->value);
return;
}
#endif
@@ -633,7 +640,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
/* GPS hardware switch (toggle switch, not momentary button).
* Needs both press (ON) and release (OFF) events,
* so handle before the release-event filter below. */
if (evt->code == INPUT_KEY_G) {
if (code == INPUT_KEY_G) {
bool gps_on = (evt->value != 0);
LOG_INF("GPS switch → %s", gps_on ? "on" : "off");
if (gps_is_available()) {
@@ -664,7 +671,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
* press get swallowed instead. */
if (!mc_display_is_on()) {
mc_display_on();
display_woken_pending = !is_ui_action_code(evt->code);
display_woken_pending = !is_ui_action_code(code);
schedule_render();
return;
}
@@ -693,7 +700,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
/* This is the action the wake-up press resolved to — swallow it so the
* press only woke the display, on whatever page it was showing. */
if (display_woken_pending && is_ui_action_code(evt->code)) {
if (display_woken_pending && is_ui_action_code(code)) {
display_woken_pending = false;
return;
}
@@ -713,7 +720,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data)
* Since INPUT_CALLBACK_DEFINE(NULL) sees events from all devices,
* the raw KEY_A events fall through to default: break.
*/
switch (evt->code) {
switch (code) {
/* ===== Multi-tap outputs ===== */
case INPUT_KEY_1:
/* First tap-codes entry (400ms delayed): page next */
@@ -358,14 +358,23 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
return;
}
/* Apply the joystick axis flip once, here, so every path below — the
* long-press timers keyed on UP/DOWN included sees the direction the
* user physically pushed on an upside-down mount.
*
* Mapped into a local rather than written back into the event: the input
* event is shared with every other INPUT_CALLBACK_DEFINE consumer on the
* bus, and this remap is a UI-layer convention, not a hardware fact. */
const uint16_t code = zephcore_input_map_code(evt->code);
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
/* When Doom is running, forward all events to it.
* BACK/ESC/KEY_1 release also enqueues KEY_CANCEL so DoomScreen can stop it. */
if (doom_game_is_running()) {
doom_game_input(evt->code, evt->value);
doom_game_input(code, evt->value);
if (!evt->value &&
(evt->code == INPUT_KEY_BACK || evt->code == INPUT_KEY_ESC ||
evt->code == INPUT_KEY_1)) {
(code == INPUT_KEY_BACK || code == INPUT_KEY_ESC ||
code == INPUT_KEY_1)) {
if (joystick_queue_initialized) {
JoystickUITask::enqueueKey(KEY_CANCEL);
}
@@ -384,9 +393,9 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
static bool s_userbtn_down;
static bool s_center_down;
static bool s_lock_combo_latched;
if (evt->code == INPUT_KEY_0) {
if (code == INPUT_KEY_0) {
s_userbtn_down = (evt->value != 0);
} else if (evt->code == INPUT_KEY_ENTER) {
} else if (code == INPUT_KEY_ENTER) {
s_center_down = (evt->value != 0);
}
if (s_userbtn_down && s_center_down) {
@@ -401,7 +410,7 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
}
/* Long press fires on RELEASE so hold duration is known */
if (evt->code == INPUT_KEY_ENTER) {
if (code == INPUT_KEY_ENTER) {
if (evt->value) {
s_enter_press_ms = k_uptime_get_32();
} else {
@@ -413,7 +422,7 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
}
return;
}
if (evt->code == INPUT_KEY_UP) {
if (code == INPUT_KEY_UP) {
if (evt->value) {
s_up_press_ms = k_uptime_get_32();
} else {
@@ -425,7 +434,7 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
}
return;
}
if (evt->code == INPUT_KEY_DOWN) {
if (code == INPUT_KEY_DOWN) {
if (evt->value) {
s_down_press_ms = k_uptime_get_32();
} else {
@@ -448,15 +457,15 @@ static void joystick_ui_input_cb(struct input_event *evt, void *user_data)
* Testing the raw event code against 0x20-0x7E instead would be wrong:
* INPUT_KEY_LEFT is 105 and INPUT_KEY_RIGHT is 106, so joystick boards
* would see their arrows turn into the letters 'i' and 'j'. */
if (ZEPHCORE_INPUT_IS_ASCII(evt->code)) {
if (ZEPHCORE_INPUT_IS_ASCII(code)) {
if (joystick_queue_initialized) {
JoystickUITask::enqueueKey(ZEPHCORE_INPUT_TO_ASCII(evt->code));
JoystickUITask::enqueueKey(ZEPHCORE_INPUT_TO_ASCII(code));
}
return;
}
char key = 0;
switch (evt->code) {
switch (code) {
case INPUT_KEY_LEFT: key = KEY_LEFT; break;
case INPUT_KEY_RIGHT: key = KEY_RIGHT; break;
case INPUT_KEY_BACK:
@@ -1109,6 +1118,38 @@ void JoystickUITask::toggleWakeOnMsg()
mesh_set_wake_on_msg(_wake_on_msg);
}
bool JoystickUITask::getDisplayRotate() const
{
return mc_display_is_rotated();
}
bool JoystickUITask::toggleDisplayRotate()
{
bool want = !mc_display_is_rotated();
/* Only persist what the panel actually did. Saving a rotation the
* hardware refused would come back at the next boot and be refused
* again, leaving the menu showing a state the screen never entered. */
if (mc_display_set_rotated(want) != 0) {
return false;
}
mesh_save_display_rotate(want);
return true;
}
bool JoystickUITask::getInputRotate() const
{
return zephcore_input_is_flipped();
}
void JoystickUITask::toggleInputRotate()
{
bool want = !zephcore_input_is_flipped();
zephcore_input_set_flipped(want);
mesh_save_input_rotate(want);
}
/* ===== Notifications from mesh ===== */
void JoystickUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount)
{
@@ -95,6 +95,15 @@ public:
void adjustBrightness(int delta);
bool getWakeOnMsg() const { return _wake_on_msg; }
void toggleWakeOnMsg();
/* Physical mounting orientation. Both persist to prefs; kept separate
* because a case can flip the screen without flipping the stick.
* Display rotation is only offered where the panel can do it in hardware
* (MC_DISPLAY_ROTATE_SUPPORTED) toggleDisplayRotate() is a no-op that
* reports failure elsewhere, so the caller can leave the row out. */
bool getDisplayRotate() const;
bool toggleDisplayRotate();
bool getInputRotate() const;
void toggleInputRotate();
/* Path-hash-mode: 0/1/2 → 1/2/3 bytes per hop appended to outbound flood
* path. Cycles through the three valid settings; persists to prefs. */
uint8_t getPathHashBytes() const { return _prefs ? (uint8_t)(_prefs->path_hash_mode + 1) : 1; }
@@ -9,6 +9,7 @@
#include "screen_helpers.h"
#include <adapters/gps/ZephyrGPSManager.h>
#include <helpers/time_sync.h>
#include <helpers/ui/display.h>
#include <helpers/ui/ui_mesh_actions.h>
#include <zephyr/kernel.h>
#include <zephyr/random/random.h>
@@ -26,7 +27,18 @@ enum SysDevItem { SYSDEV_BUZZER=0, SYSDEV_BLUETOOTH, SYSDEV_OFFGRID, SYSDEV_LEDS
#define DFU_CONFIRM_WINDOW_MS 3000
/* Display submenu items */
enum SysDspItem { SYSDSP_BRIGHT=0, SYSDSP_SCROFF, SYSDSP_BATT, SYSDSP_WAKE, SYSDSP_COUNT };
/* SYSDSP_ROTATE is present only where the panel can flip itself in hardware
* (SSD1306 / SH1106 see MC_DISPLAY_ROTATE_SUPPORTED). Offering a row that
* always fails would be worse than not offering it, and dropping it at
* compile time keeps the flash cost at zero on the other boards.
* SYSDSP_INPUT_ROTATE is unconditional: the axis swap is ours, not the
* panel's, so it works everywhere including boards whose screen cannot
* rotate but whose stick still ends up upside down in a custom case. */
enum SysDspItem { SYSDSP_BRIGHT=0, SYSDSP_SCROFF, SYSDSP_BATT, SYSDSP_WAKE,
#if MC_DISPLAY_ROTATE_SUPPORTED
SYSDSP_ROTATE,
#endif
SYSDSP_INPUT_ROTATE, SYSDSP_COUNT };
/* Info submenu items */
enum SysInfoItem { SYSINFO_TIME=0, SYSINFO_STATS, SYSINFO_RADIO, SYSINFO_COUNT };
/* Power submenu items */
@@ -92,6 +104,10 @@ int SystemScreen::render(JoystickDisplay &display)
items[SYSDSP_SCROFF] = scroff_label;
items[SYSDSP_BATT] = batt_label;
items[SYSDSP_WAKE] = _task->getWakeOnMsg() ? "Wake on msg: ON" : "Wake on msg: OFF";
#if MC_DISPLAY_ROTATE_SUPPORTED
items[SYSDSP_ROTATE] = _task->getDisplayRotate() ? "Rotate 180: ON" : "Rotate 180: OFF";
#endif
items[SYSDSP_INPUT_ROTATE] = _task->getInputRotate() ? "Flip input: ON" : "Flip input: OFF";
renderSubMenu(display, "Display", _selected, items, SYSDSP_COUNT);
return 500;
}
@@ -213,6 +229,19 @@ bool SystemScreen::handleInput(char c)
_task->toggleWakeOnMsg();
_task->showAlert(_task->getWakeOnMsg() ? "Wake on msg: ON" : "Wake on msg: OFF", 1000);
return true;
#if MC_DISPLAY_ROTATE_SUPPORTED
case SYSDSP_ROTATE:
if (!_task->toggleDisplayRotate()) {
_task->showAlert("Rotate unsupported", 1500);
return true;
}
_task->showAlert(_task->getDisplayRotate() ? "Rotate 180: ON" : "Rotate 180: OFF", 1000);
return true;
#endif
case SYSDSP_INPUT_ROTATE:
_task->toggleInputRotate();
_task->showAlert(_task->getInputRotate() ? "Flip input: ON" : "Flip input: OFF", 1000);
return true;
default: return false;
}
}
+38
View File
@@ -48,6 +48,7 @@ LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL);
static const struct device *disp_dev;
static bool disp_on;
static bool disp_initialized;
static bool disp_rotated; /* panel flipped 180 degrees (upside-down mount) */
/* Runtime display geometry (queried from driver) */
static uint16_t disp_width;
@@ -635,6 +636,43 @@ bool mc_display_is_epd(void)
return is_epd;
}
int mc_display_set_rotated(bool rotated)
{
#if !MC_DISPLAY_ROTATE_SUPPORTED
ARG_UNUSED(rotated);
return -ENOTSUP;
#else
if (!disp_initialized) {
return -ENODEV;
}
if (rotated == disp_rotated) {
return 0;
}
/* Panel-level remap: the driver rewrites SEGMENT_MAP + COM_OUTPUT_SCAN
* and the existing framebuffer contents come back out mirrored on both
* axes. Nothing to redraw, and no cost on subsequent frames. */
int ret = display_set_orientation(disp_dev,
rotated ? DISPLAY_ORIENTATION_ROTATED_180
: DISPLAY_ORIENTATION_NORMAL);
if (ret) {
LOG_WRN("display rotate %s failed: %d",
rotated ? "180" : "normal", ret);
return ret;
}
disp_rotated = rotated;
LOG_INF("display rotated %s", rotated ? "180" : "normal");
return 0;
#endif
}
bool mc_display_is_rotated(void)
{
return disp_rotated;
}
#if MC_DISPLAY_COLOR_PANEL
bool mc_display_has_color(void)
{
+81
View File
@@ -111,6 +111,87 @@ bool mc_display_is_epd(void);
#define MC_DISPLAY_COLOR_PANEL DT_NODE_EXISTS(MC_DISPLAY_COLOR_NODE)
/*
* The panel node mc_display_init() will bind to, resolved at compile time in
* the same priority order the runtime lookup uses. Only needed to ask
* compile-time questions about the panel; the device handle itself still
* comes from the runtime lookup.
*/
#if DT_HAS_CHOSEN(zephyr_display)
#define MC_DISPLAY_NODE DT_CHOSEN(zephyr_display)
#elif DT_NODE_EXISTS(DT_NODELABEL(sh1106))
#define MC_DISPLAY_NODE DT_NODELABEL(sh1106)
#elif DT_NODE_EXISTS(DT_NODELABEL(ssd1306))
#define MC_DISPLAY_NODE DT_NODELABEL(ssd1306)
#else
#define MC_DISPLAY_NODE DT_INVALID_NODE
#endif
/*
* 180-degree rotation support, decided at compile time from the panel.
*
* Deliberately an allow-list of the two families where the rotation is a
* hardware remap the driver actually implements: display_ssd1306.c flips
* SEGMENT_MAP + COM_OUTPUT_SCAN (two bytes on the wire, framebuffer
* untouched, no per-frame cost), and that driver backs both solomon,ssd1306
* and sinowealth,sh1106.
*
* Other panel families are excluded on purpose rather than probed:
* - st7735r/st7789v (our mono-tft boards) return -ENOTSUP upstream for
* anything but NORMAL, so a probe would just fail;
* - ssd16xx e-paper *accepts* ROTATED_180 but implements it by flipping
* the RAM entry mode only, which reverses byte order without reversing
* bit order inside each byte the 8 pixels a byte spans stay in their
* original order. It would report success and render wrong, which is
* worse than reporting unsupported.
*
* The geometry test on top of the family check matters as much as the family
* check itself. Both remaps reverse the controller's *entire* addressable
* range, not the part a given panel happens to use, so the flip only lands
* back on the glass when the visible window is centred in that range:
*
* - Vertically, that means the panel uses the full multiplex height from
* page 0. Boards that window a small panel into a larger controller do
* not: lilygo_timpulse_plus is a 64x32 glass on a 128x64 SSD1306 driven
* at page-offset 4 with multiplex-ratio 63, so its content sits on COM
* 32..63. Reversing the COM scan moves it to COM 31..0 off the bonded
* region entirely, i.e. a blank screen. `page-offset == 0` and
* `height == multiplex-ratio + 1` is exactly the "uses the whole
* controller" condition, and it excludes that board.
* - Horizontally the surviving boards are already centred: the SSD1306
* ones are a full 128 columns at segment-offset 0, and the SH1106 ones
* are 128 columns at segment-offset 2 in 132 columns of RAM the
* standard 2-either-side layout these modules ship with.
*/
#if DT_NODE_HAS_COMPAT(MC_DISPLAY_NODE, solomon_ssd1306) || \
DT_NODE_HAS_COMPAT(MC_DISPLAY_NODE, sinowealth_sh1106)
#define MC_DISPLAY_ROTATE_SUPPORTED \
(DT_PROP_OR(MC_DISPLAY_NODE, page_offset, 1) == 0 && \
DT_PROP_OR(MC_DISPLAY_NODE, height, 0) == \
DT_PROP_OR(MC_DISPLAY_NODE, multiplex_ratio, 0) + 1)
#else
#define MC_DISPLAY_ROTATE_SUPPORTED 0
#endif
/**
* Rotate the panel 180 degrees, for cases that mount the screen upside down
* (e.g. the Meshnology N37E kit for the Wio Tracker L1).
*
* Takes effect on the next frame; the caller does not need to redraw. On
* panels outside MC_DISPLAY_ROTATE_SUPPORTED this is a no-op that reports
* the failure instead of pretending to have rotated.
*
* @param rotated true for 180 degrees, false for the panel's native orientation
* @return 0 on success, -ENOTSUP if the panel cannot rotate, -ENODEV if no
* display was initialized, or the driver's negative errno
*/
int mc_display_set_rotated(bool rotated);
/**
* @return true if the panel is currently rotated 180 degrees.
*/
bool mc_display_is_rotated(void);
/**
* @return true when a raw RGB565-capable color panel is available for
* optional color overlays. Monochrome displays return false.
+37
View File
@@ -22,6 +22,7 @@
#include "led_gate.h" /* shared with the LoRa TX LED */
#include <zephyr/drivers/gpio.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/kernel.h>
#include <string.h>
@@ -36,6 +37,42 @@
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(ui_led, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL);
/* ========== Input axis flip ==========
*
* Shared by both UI variants so an upside-down mount only has to be
* configured once. Written from the mesh/CLI thread, read from the input
* callback. A plain bool needs no atomic here: it is a single aligned byte,
* and the only race a keypress landing in the same instant the setting is
* toggled costs that one keypress its direction, which is what toggling an
* axis swap does anyway. */
static bool input_flipped;
void zephcore_input_set_flipped(bool flipped)
{
input_flipped = flipped;
}
bool zephcore_input_is_flipped(void)
{
return input_flipped;
}
uint16_t zephcore_input_map_code(uint16_t code)
{
if (!input_flipped) {
return code;
}
switch (code) {
case INPUT_KEY_UP: return INPUT_KEY_DOWN;
case INPUT_KEY_DOWN: return INPUT_KEY_UP;
case INPUT_KEY_LEFT: return INPUT_KEY_RIGHT;
case INPUT_KEY_RIGHT: return INPUT_KEY_LEFT;
default: return code;
}
}
/* ========== Startup Chime ========== */
void ui_play_startup_chime(void)
+8
View File
@@ -175,3 +175,11 @@ WEAK void ui_notify_channel_msg(const char *channel_name, const char *text,
}
WEAK void ui_notify_packet_sent(void) { }
/* Input axis flip. The real state lives in ui_common.c, which is compiled
* whenever any UI hardware is present; a headless build has no input to flip,
* so the setter is a no-op and the mapper is the identity. The CLI calls
* these unconditionally. */
WEAK void zephcore_input_set_flipped(bool flipped) { ARG_UNUSED(flipped); }
WEAK bool zephcore_input_is_flipped(void) { return false; }
WEAK uint16_t zephcore_input_map_code(uint16_t code) { return code; }
+30
View File
@@ -46,6 +46,8 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL);
#define UI_ACTION_SCREEN_OFF_SAVE BIT(10)
#define UI_ACTION_PATH_HASH_MODE_SAVE BIT(11)
#define UI_ACTION_GPS_DUTY_SAVE BIT(12)
#define UI_ACTION_DISPLAY_ROTATE_SAVE BIT(13)
#define UI_ACTION_INPUT_ROTATE_SAVE BIT(14)
/* Module-local pointers, set by init */
static CompanionMesh *s_mesh;
@@ -72,6 +74,8 @@ static atomic_t pending_wake_on_msg;
static atomic_t pending_screen_off_secs;
static atomic_t pending_path_hash_mode;
static atomic_t pending_gps_duty_sec;
static atomic_t pending_display_rotate;
static atomic_t pending_input_rotate;
extern "C" void ui_mesh_actions_init(struct k_event *mesh_events,
uint32_t mesh_event_ui_action,
@@ -148,6 +152,20 @@ extern "C" void mesh_save_screen_off_secs(uint16_t secs)
k_event_post(s_mesh_events, s_mesh_event_ui_action);
}
extern "C" void mesh_save_display_rotate(bool rotated)
{
atomic_set(&pending_display_rotate, rotated ? 1 : 0);
atomic_or(&pending_ui_actions, UI_ACTION_DISPLAY_ROTATE_SAVE);
k_event_post(s_mesh_events, s_mesh_event_ui_action);
}
extern "C" void mesh_save_input_rotate(bool rotated)
{
atomic_set(&pending_input_rotate, rotated ? 1 : 0);
atomic_or(&pending_ui_actions, UI_ACTION_INPUT_ROTATE_SAVE);
k_event_post(s_mesh_events, s_mesh_event_ui_action);
}
extern "C" void mesh_save_path_hash_mode(uint8_t mode)
{
atomic_set(&pending_path_hash_mode, (atomic_val_t)mode);
@@ -296,6 +314,18 @@ extern "C" void mesh_handle_ui_actions(void)
need_save = true;
}
if (actions & UI_ACTION_DISPLAY_ROTATE_SAVE) {
s_mesh->prefs.display_rotate = atomic_get(&pending_display_rotate) ? 1 : 0;
LOG_INF("display_rotate=%d (button)", s_mesh->prefs.display_rotate);
need_save = true;
}
if (actions & UI_ACTION_INPUT_ROTATE_SAVE) {
s_mesh->prefs.input_rotate = atomic_get(&pending_input_rotate) ? 1 : 0;
LOG_INF("input_rotate=%d (button)", s_mesh->prefs.input_rotate);
need_save = true;
}
if (actions & UI_ACTION_PATH_HASH_MODE_SAVE) {
uint8_t mode = (uint8_t)atomic_get(&pending_path_hash_mode);
if (mode > 2) mode = 2; /* clamp to valid range (0-2 → 1-3 bytes) */
+4
View File
@@ -64,6 +64,10 @@ void mesh_save_brightness(uint8_t brightness);
void mesh_save_and_restart(void);
void mesh_set_wake_on_msg(bool enabled);
void mesh_save_screen_off_secs(uint16_t secs);
/* Mounting orientation. Both are applied live by the caller (panel remap /
* axis swap are immediate and cheap); these only persist the choice. */
void mesh_save_display_rotate(bool rotated);
void mesh_save_input_rotate(bool rotated);
/* path_hash_mode: 0/1/2 → 1/2/3 bytes per hop appended to outbound flood path */
void mesh_save_path_hash_mode(uint8_t mode);
/* GPS duty-cycle interval in seconds (0 = always on). Applied live immediately
@@ -22,5 +22,9 @@ __attribute__((weak)) void mesh_set_offgrid_mode(bool enable) { ARG_UNUSED(enabl
__attribute__((weak)) void mesh_set_leds_disabled(bool disabled) { ARG_UNUSED(disabled); }
__attribute__((weak)) void mesh_disable_power_regulators(void) {}
__attribute__((weak)) void mesh_reboot_to_ota_dfu(void) {}
/* Repeater builds persist orientation through RepeaterDataStore in
* main_repeater.cpp's CLI path, not through the companion action queue. */
__attribute__((weak)) void mesh_save_display_rotate(bool rotated) { ARG_UNUSED(rotated); }
__attribute__((weak)) void mesh_save_input_rotate(bool rotated) { ARG_UNUSED(rotated); }
__attribute__((weak)) void mesh_handle_ui_actions(void) {}
__attribute__((weak)) void mesh_housekeeping_ui_refresh(void) {}
+37
View File
@@ -306,6 +306,43 @@ void ui_notify_channel_msg(const char *channel_name, const char *text,
*/
void ui_notify_packet_sent(void);
/* ===== Input axis flip =====
*
* A case that mounts the board upside down (e.g. the Meshnology N37E kit)
* rotates the joystick along with the screen, so "up" on the stick walks the
* menu down. This flips the two axes back.
*
* Kept separate from the display rotation on purpose: the two are not always
* wanted together a panel can be remounted alone, and boards whose display
* cannot rotate can still benefit from the axis swap.
*
* The state lives in ui_common.c so both UI variants (button and joystick)
* share one source of truth; each variant's input callback runs its raw
* event code through zephcore_input_map_code() before decoding it.
*/
/**
* Enable or disable the joystick/D-pad axis swap.
*
* @param flipped true to swap up/down and left/right
*/
void zephcore_input_set_flipped(bool flipped);
/**
* @return true if the input axes are currently swapped.
*/
bool zephcore_input_is_flipped(void);
/**
* Map a raw Zephyr INPUT_KEY_* code through the current axis flip.
* Returns @p code unchanged when the flip is off or the code is not
* a directional key.
*
* @param code Raw input event code
* @return The code the UI should decode
*/
uint16_t zephcore_input_map_code(uint16_t code);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,68 @@
# ZephCore 1.17.4-zephcore
Two new settings for people who put their node in a case it did not come in: the screen can be turned
upside down, and the joystick can be flipped to match.
> [!NOTE]
> Upgrading from 1.17.3 is straightforward — your contacts, settings and phone pairing all survive.
> Both new settings default to off, so a node you do not touch behaves exactly as it did.
---
## The screen can be turned upside down
DIY builds and upgrade kits do not always hold the board the way the original case did. The
**Meshnology N37E**, for instance, takes a Wio Tracker L1 and puts it in a different enclosure — one
that mounts the screen the other way up. Until now the only answer was to build your own firmware with
the panel flipped, which meant giving up the published builds everyone else uses.
`set display.rotate on` turns the picture 180 degrees. It survives reboots, and the same firmware
everybody else runs is now the firmware you run too.
On the boards with a joystick there is a menu entry as well — **System → Display → Rotate 180** — so it
can be set on the device without a cable.
The rotation costs nothing to keep on. It is not done by redrawing the screen the other way round; the
firmware asks the display chip itself to scan its rows and columns in reverse, which is two bytes sent
once. Every frame after that is drawn exactly as before.
> [!IMPORTANT]
> **This works on OLED screens only** — the SSD1306 and SH1106 panels, which covers the **RAK4631**,
> **GAT562 30S**, **Heltec WiFi LoRa 32 V4** and **V4.3**, **LilyGo T3S3**, **Station G2** and
> **Wio Tracker L1**.
>
> Boards with a colour screen or e-paper answer `Error: this panel cannot rotate`, and the setting is
> not saved — so a stored value can never claim something the screen is not doing. Those panels can be
> rotated in principle, but it would mean turning every frame around in software rather than asking the
> chip to do it, and that is a different piece of work. The joystick flip below works on all of them.
> [!NOTE]
> **The LilyGo T-Impulse Plus is the one OLED board left out.** Its small screen only occupies part of
> the display chip's memory, and the trick that flips the picture flips the chip's whole range — which
> would have slid the image off the visible area rather than turning it over. It reports the setting as
> unsupported, like the colour and e-paper boards.
## The joystick can be flipped to match
Turning the screen over does not turn the buttons over. A case that mounts the board upside down puts
the joystick upside down as well, so pushing up walks the menu down and left goes right.
`set input.rotate on` swaps both axes back. On the joystick boards there is a menu entry next to the
rotation one — **System → Display → Flip input**.
It is deliberately a **separate** setting rather than part of the rotation. A case can put the screen
somewhere new without moving the stick, and boards whose screen cannot rotate can still end up with the
controls the wrong way round — so tying the two together would have made both less useful. Set whichever
you actually need.
Only the four directions are affected. Buttons, tap gestures and long presses are untouched, and on the
single-button boards the flip swaps which way the pages step.
---
Both settings are readable with `get display.rotate` and `get input.rotate`. `get display.rotate`
reports what the panel is actually doing rather than what is stored, so a refused rotation shows up as
refused.
Suggested by **celsoazevedo** ([@celsoazevedo](https://github.com/celsoazevedo)) in
[issue #76](https://github.com/liquidraver/ZephCore/issues/76).
+14
View File
@@ -29,6 +29,9 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <helpers/time_sync.h>
#include "ui_task.h"
#include "ui_mesh_actions.h"
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
#include "display.h"
#endif
#include "oled_power.h"
#include "led_gate.h"
#include "buzzer_gate.h"
@@ -1522,6 +1525,17 @@ int main(void)
companion_mesh.vcontactNotify(boot_cause_msg);
}
/* Physical mounting orientation, from prefs. This is the earliest it can
* run ui_init() brings the panel up before prefs are loaded, so the
* splash may flash upright for a moment before the remap lands. A panel
* that cannot rotate logs a warning and stays native. */
zephcore_input_set_flipped(companion_mesh.prefs.input_rotate != 0);
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
if (companion_mesh.prefs.display_rotate) {
mc_display_set_rotated(true);
}
#endif
/* Push initial state to UI display */
ui_set_node_name(companion_mesh.prefs.node_name);
ui_set_radio_params(
+15
View File
@@ -57,6 +57,9 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
/* UI subsystem (display, buttons, buzzer) */
#include "ui_task.h"
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
#include "display.h"
#endif
/* Headless repeaters link the weak no-op ui_* stubs (ui_headless_stubs.c), so
* the periodic UI refresh in the maintenance pass is pure work for nothing on
@@ -762,6 +765,18 @@ int main(void)
}
}
/* Physical mounting orientation, from prefs. Both are immediate and
* cheap: the panel rotation is a two-byte SEGMENT_MAP/COM_SCAN remap and
* the axis swap is a flag the input callbacks read per event. A panel
* that cannot rotate logs a warning and stays in its native orientation
* rather than failing the boot. */
zephcore_input_set_flipped(prefs->input_rotate != 0);
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
if (prefs->display_rotate) {
mc_display_set_rotated(true);
}
#endif
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);
refresh_repeater_ui_radio_state();