diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index d8d6a5e..4a04dd1 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -274,6 +274,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `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 tz.offset` | Display timezone: whole hours from UTC, `-12`..`14` (`0` = UTC) | | `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. | @@ -345,6 +346,7 @@ four radio parameters together, since they are one interop-critical set. | `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 tz.offset <-12..14>` | default **0** (UTC) | Whole-hour offset from UTC applied when formatting the **on-device clock display** — the top bar, the status page and the joystick System -> Time screen, which show e.g. `UTC+2` instead of `UTC`. Accepts `default`. **Display only, by design.** The RTC itself, `clock`, `clock sync` and `time ` all stay UTC: they round-trip with each other, apps parse them, and an offset that reached the clock would read as a jump to every timestamp consumer on the node (advert timestamps, the ACL's monotonic `sender_timestamp` gate, MeshTimeSync) — a backward clock silently mutes a node on the mesh. Whole hours only, matching upstream MeshCore's command of the same name, so half-hour zones (+5:30, -3:30) cannot be expressed. Accepted on headless nodes, where it is simply inert. | | `set adc.multiplier ` | `0` (use board default) or 100–30000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. | | `set adc.multiplier target ` | 3000–4400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads ``. 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. | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 350ab22..d5cc05c 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -844,6 +844,14 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) prefs.cad_base = buf[off++]; } + /* Offset 171: tz_offset (ZephCore extension, signed whole hours). + * Absent in pre-existing files → stays 0 = UTC, which is exactly what + * every already-deployed node displays today. Range is re-checked by + * sanitizeNodePrefs() below. */ + if (off < len) { + prefs.tz_offset = (int8_t)buf[off++]; + } + sanitizeNodePrefs(&prefs); } @@ -952,7 +960,10 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 170: cad_base (ZephCore extension, family base detPeak the * cad_offset above was learned against) */ buf[off++] = prefs.cad_base; - /* Total: 171 bytes */ + /* Offset 171: tz_offset (ZephCore extension, signed whole hours from UTC, + * display only — the stored clock is always UTC) */ + buf[off++] = (uint8_t)prefs.tz_offset; + /* Total: 172 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 417e88c..c3521f2 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -283,6 +283,10 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { * alone — correct, since a node upgrading across a table change cannot * know which base its offset came from. */ fs_read(&file, &prefs.cad_base, sizeof(prefs.cad_base)); + /* Display timezone offset, offset 308. Absent in <309-byte files; the + * no-op EOF read leaves 0 = UTC, which is what every already-deployed + * node shows today. Range is re-checked by sanitizeNodePrefs(). */ + fs_read(&file, &prefs.tz_offset, sizeof(prefs.tz_offset)); fs_close(&file); @@ -435,6 +439,9 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { fs_write(&file, &prefs.input_rotate, sizeof(prefs.input_rotate)); /* Family base detPeak cad_offset was learned against (offset 307) */ fs_write(&file, &prefs.cad_base, sizeof(prefs.cad_base)); + /* Display timezone offset (offset 308) — signed whole hours from UTC, + * applied only when formatting the on-device clock */ + fs_write(&file, &prefs.tz_offset, sizeof(prefs.tz_offset)); ret = fs_sync(&file); fs_close(&file); diff --git a/zephcore/boards/esp32/meshnology_w12/board.conf b/zephcore/boards/esp32/meshnology_w12/board.conf index e0340a1..32f9981 100644 --- a/zephcore/boards/esp32/meshnology_w12/board.conf +++ b/zephcore/boards/esp32/meshnology_w12/board.conf @@ -27,18 +27,29 @@ CONFIG_SPIRAM_MODE_OCT=y # ========== TX power: the number here is CHIP output, not antenna output ========== # -# The GC1109 sits between the LR2021 and the antenna and adds roughly 26 dB. -# The vendor rates the finished board at 30 +/- 1 dBm conducted, and MeshCore's -# variant notes the part's absolute maximum TX-port input is +5 dBm with full -# saturation already reached around 3-4 dBm. So 4 dBm at the chip is both the -# most that is useful and the most that is safe: beyond it the extra drive turns -# into distortion and heat, not range. +# The GC1109 sits between the LR2021 and the antenna. These values come from a +# measured sweep on real hardware (tinySA, MeshCore PR #3319), which replaced an +# earlier reading taken from upstream's variant comment -- that comment claimed +# the part's absolute maximum TX-port input was +5 dBm with full saturation +# around 3-4 dBm, and the sweep contradicts both halves: the board was driven +# across the full 0..22 dBm range without damage, and output keeps climbing to +# 13 dBm before it flattens. # -# Cap and default are deliberately the same value. Unlike Station G2 (default 15, -# cap 19) there is no useful headroom above saturation to leave a user, and the -# knob still spans -9..4 downward — roughly 17..30 dBm at the antenna — which is -# the range that matters for staying inside a regional ERP limit. Anyone setting -# this for EU 868 should note that even -9 dBm at the chip is well above 14 dBm -# ERP once the FEM has had its way. -CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=4 -CONFIG_ZEPHCORE_MAX_TX_POWER_DBM=4 +# chip dBm | 4 5 9 12 13 16 22 +# measured | 20.6 19.1 25.1 26.6 27.1 27.1 27.1 +# +# So 13 dBm is the saturation knee and the useful ceiling; above it the extra +# drive turns into distortion and heat, not range. The old 4 dBm cap left the +# board about 6.5 dB down on what it can do. +# +# Note the vendor's 30 +/- 1 dBm conducted rating is not reached at any setting +# in that sweep -- a tinySA is +/-1-2 dB on absolute level, so the true ceiling +# is plausibly ~28-29 dBm, but it is not 30 dBm at 4 dBm of drive. +# +# The default is deliberately below the cap (unlike the old same-value pair, and +# like Station G2's 15/19): 5 dBm is roughly 19-22 dBm at the antenna, a sane +# out-of-the-box level, with the rest available to anyone who wants it. The knob +# still spans -9 dBm downward for regional ERP limits -- but note this is a 1 W +# class node and even the minimum is above the EU 868 14 dBm ERP limit. +CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=5 +CONFIG_ZEPHCORE_MAX_TX_POWER_DBM=13 diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index f527f3f..aae78b1 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -511,6 +511,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch #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, "tz.offset", 9) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tz_offset); } else if (memcmp(config, "gps diag", 8) == 0) { // What the last module-configuration attempt actually did. reply[0] = '>'; reply[1] = ' '; @@ -1209,6 +1211,28 @@ 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, "tz.offset ", 10) == 0) { + // Whole-hour offset from UTC for the ON-DEVICE CLOCK DISPLAY only. + // The RTC, `clock` and `time ` all stay UTC: they round-trip + // with each other, apps parse them, and a timezone that reached the + // clock would look like a backward jump to every timestamp consumer + // on the node (see NodePrefs::tz_offset). + // + // Whole hours, matching upstream MeshCore's command of the same + // name, so an app that speaks to both trees behaves identically. + // Half-hour zones (+5:30, -3:30) cannot be expressed. + long val; + if (!cliNum(&config[10], cliDefaults()->tz_offset, &val)) { + snprintf(reply, CLI_REPLY_SIZE, "Error: expected %d..%d or default", + TZ_OFFSET_MIN, TZ_OFFSET_MAX); + } else if (val < TZ_OFFSET_MIN || val > TZ_OFFSET_MAX) { + snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", + TZ_OFFSET_MIN, TZ_OFFSET_MAX); + } else { + _prefs->tz_offset = (int8_t)val; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - tz.offset=%d", (int)val); + } } 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. diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index fa83ae8..a4b9984 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -56,6 +56,11 @@ * ConfigureSideDetectors limit of 3. */ #define EXTRA_SF_MAX 3 +/* Display timezone offset range, whole hours from UTC. Matches upstream + * MeshCore's `set tz.offset` so the same command works against both trees. */ +#define TZ_OFFSET_MIN (-12) +#define TZ_OFFSET_MAX 14 + struct NodePrefs { /* ---- Common fields (both roles) ---- */ float airtime_factor; @@ -136,6 +141,23 @@ struct NodePrefs { uint8_t display_rotate; // 1 = panel rotated 180 degrees uint8_t input_rotate; // 1 = joystick up/down and left/right swapped + /* Whole-hour offset from UTC, -12..+14, applied ONLY when formatting a + * clock for the local display. It must never reach RTCClock or any + * timestamp that leaves this node: a negative offset applied to the clock + * itself reads as a backward jump to every timestamp consumer at once + * (advert timestamps, the repeater ACL's monotonic sender_timestamp gate, + * discovery_mod_timestamp, MeshTimeSync), and a backward clock is a silent + * mesh-wide mute. The CLI's `clock` / `time` commands stay UTC for the + * same reason -- they round-trip with each other and apps parse them. + * + * Whole hours only, matching upstream's `set tz.offset` so an app that + * speaks to both trees behaves the same. Half-hour zones (India +5:30, + * Newfoundland -3:30) therefore cannot be expressed. + * + * Common to both roles: a repeater with an OLED shows a clock too. On a + * headless repeater the field is simply inert. */ + int8_t tz_offset; + /* The family base detPeak that cad_offset was learned against (0 = never * stored, i.e. a node upgrading from a build without this field). * @@ -282,6 +304,8 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { /* Neutral, not the boundary — a garbage offset is not a request to run * the CAD staircase at one end of its range. */ if (p->cad_offset < CAD_OFFSET_MIN || p->cad_offset > CAD_OFFSET_MAX) p->cad_offset = 0; + /* UTC, not a boundary zone, for a byte that carries no user intent. */ + if (p->tz_offset < TZ_OFFSET_MIN || p->tz_offset > TZ_OFFSET_MAX) p->tz_offset = 0; if (p->probe_interval != 0 && p->probe_interval < 10) p->probe_interval = 10; /* 6-digit BLE passkey — anything else is rejected at pairing time and diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 5356a46..0822baf 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -206,6 +206,31 @@ static uint8_t calc_battery_pct(uint16_t mv) return (uint8_t)((mv - 3000) * 100 / 1200); } +/* ========== Helper: local wall clock ========== + * + * state.rtc_epoch is ALWAYS UTC. The timezone offset is applied here, at + * format time, and nowhere else: shifting the clock itself would read as a + * backward jump to every timestamp consumer on the node (advert timestamps, + * the repeater ACL's monotonic gate, MeshTimeSync), and a backward clock is a + * silent mesh-wide mute. Never write these values back into state. */ +static uint32_t local_epoch(void) +{ + return state.rtc_epoch + (int32_t)state.tz_offset * 3600; +} + +/* The zone the displayed digits are actually in: "UTC", "UTC+2", "UTC-11". + * Static buffer -- every caller is on the UI thread inside ui_pages_render(). */ +static const char *tz_label(void) +{ + static char label[8]; + + if (state.tz_offset == 0) { + return "UTC"; + } + snprintf(label, sizeof(label), "UTC%+d", (int)state.tz_offset); + return label; +} + /* ========== Helper: Top Bar (Node Name + Battery) ========== */ /* One-char tag for where the displayed clock was last freshly synced from @@ -239,7 +264,7 @@ static void render_top_bar(void) * Before sync, getCurrentTime() returns bare uptime (~seconds), * so check for a sane epoch (after Jan 1 2025 = 1735689600). */ if (state.rtc_epoch > 1735689600) { - uint32_t day_sec = state.rtc_epoch % 86400; + uint32_t day_sec = local_epoch() % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; @@ -1554,10 +1579,11 @@ static void render_status(void) draw_centered(centered_row(0, 3), buf); if (state.rtc_epoch > 1735689600) { - uint32_t ds = state.rtc_epoch % 86400; + uint32_t ds = local_epoch() % 86400; - snprintf(buf, sizeof(buf), "%02u:%02u UTC", - (unsigned)(ds / 3600), (unsigned)((ds % 3600) / 60)); + snprintf(buf, sizeof(buf), "%02u:%02u %s", + (unsigned)(ds / 3600), (unsigned)((ds % 3600) / 60), + tz_label()); } else { snprintf(buf, sizeof(buf), "no time"); } @@ -1598,12 +1624,16 @@ static void render_status(void) /* Clock — only if RTC has been synced (after Jan 1 2025) */ if (state.rtc_epoch > 1735689600) { - uint32_t day_sec = state.rtc_epoch % 86400; + uint32_t day_sec = local_epoch() % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; uint8_t ss = day_sec % 60; - snprintf(buf, sizeof(buf), "Time: %02u:%02u:%02u UTC", hh, mm, ss); + /* "T:" rather than "Time:" so a two-digit zone still fits the + * 20-column budget of the 200x200 e-paper boards, which pair + * CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT (10x16) with this page. */ + snprintf(buf, sizeof(buf), "T: %02u:%02u:%02u %s", hh, mm, ss, + tz_label()); if (color) { draw_color_segments(y, "CLK ", buf, UI_COLOR_OK); } else { @@ -1613,7 +1643,7 @@ static void render_status(void) if (color) { draw_color_segments(y, "CLK ", "not synced", UI_COLOR_WARN); } else { - mc_display_text(0, y, "Time: not synced", false); + mc_display_text(0, y, "T: not synced", false); } } y += LINE_H; diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index 1e691d3..b924f22 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -43,7 +43,9 @@ struct ui_state { char node_name[24]; /* device node name for top bar */ uint16_t battery_mv; uint8_t battery_pct; - uint32_t rtc_epoch; /* Unix epoch from RTC (0 = not set) */ + uint32_t rtc_epoch; /* Unix epoch from RTC, ALWAYS UTC (0 = not set) */ + int8_t tz_offset; /* whole hours from UTC, applied at format time + * only -- rtc_epoch itself is never shifted */ /* Messages page */ uint16_t msg_count; diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 71c637b..969cbe8 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -1092,6 +1092,20 @@ void ui_set_clock(uint32_t epoch) } } +void ui_set_tz(int8_t hours) +{ + struct ui_state *s = get_state(); + + if (s->tz_offset == hours) { + return; + } + s->tz_offset = hours; + + /* EPD only redraws on demand, so a timezone change would otherwise not + * appear until something else forced a repaint. */ + schedule_render(); +} + void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s) { struct ui_state *s = get_state(); diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index c8532ef..36b64a0 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -268,6 +268,8 @@ extern "C" void ui_set_ble_enabled(bool enabled) * push-model hooks from the old button UI have no work to do. */ extern "C" void ui_set_gps_data(bool, uint8_t, int32_t, int32_t, int32_t) {} extern "C" void ui_set_clock(uint32_t) {} +/* The joystick UI reads tz_offset straight off NodePrefs at render time. */ +extern "C" void ui_set_tz(int8_t) {} extern "C" void ui_add_recent(const char *, int16_t, uint32_t) {} extern "C" void ui_set_node_name(const char *) {} extern "C" void ui_clear_recent(void) {} diff --git a/zephcore/helpers/ui-joystick/screens/system.cpp b/zephcore/helpers/ui-joystick/screens/system.cpp index c96d43f..5d5bdf7 100644 --- a/zephcore/helpers/ui-joystick/screens/system.cpp +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -328,11 +328,31 @@ int SystemTimeScreen::render(JoystickDisplay &display) display.setTextSize(1); display.setColor(JoystickDisplay::GREEN); - char timeText[16], dateText[16]; - formatUnixDateTime(_rtc->getCurrentTime(), timeText, sizeof(timeText), dateText, sizeof(dateText)); + /* The RTC is UTC; the offset is applied here, at format time only. It + * must never reach the clock itself -- see NodePrefs::tz_offset. */ + NodePrefs *prefs = _task->getPrefs(); + int8_t tz = prefs ? prefs->tz_offset : 0; + uint32_t epoch = _rtc->getCurrentTime(); + + /* Before a sync getCurrentTime() returns bare uptime, and a negative + * offset would underflow it into a garbage far-future date. Same + * "time is set" threshold (2025-01-01) the other UI uses. */ + if (epoch > 1735689600) { + epoch += (int32_t)tz * 3600; + } else { + tz = 0; + } + + char timeText[16], dateText[16], tzText[8]; + formatUnixDateTime(epoch, timeText, sizeof(timeText), dateText, sizeof(dateText)); + if (tz == 0) { + strcpy(tzText, "UTC"); + } else { + snprintf(tzText, sizeof(tzText), "UTC%+d", (int)tz); + } const char *labels[4] = { "Time:", "Date:", "TZ:", "Source:" }; - const char *values[4] = { timeText, dateText, "UTC", + const char *values[4] = { timeText, dateText, tzText, time_sync_display_label() }; int y = kContentY + 2; for (int i = 0; i < 4; i++) { diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index e22c922..0909a8e 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -82,6 +82,11 @@ WEAK void ui_set_clock(uint32_t epoch) ARG_UNUSED(epoch); } +WEAK void ui_set_tz(int8_t hours) +{ + ARG_UNUSED(hours); +} + WEAK void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s) { ARG_UNUSED(name); ARG_UNUSED(rssi); ARG_UNUSED(age_s); diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index fe97fc4..21acdc7 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -362,8 +362,11 @@ extern "C" void mesh_housekeeping_ui_refresh(void) /* Battery is now refreshed lazily from ui_pages_render() with a 30 s * freshness guard — no periodic ADC fire here. */ - /* Update top bar clock from RTC */ + /* Update top bar clock from RTC. The epoch pushed here is UTC; the + * display offset is a separate push so nothing downstream is tempted to + * bake a timezone into a timestamp (see NodePrefs::tz_offset). */ ui_set_clock(s_rtc_clock->getCurrentTime()); + ui_set_tz(s_mesh->prefs.tz_offset); ui_set_radio_params( s_lora_radio->getActiveFrequencyHz(), diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index 558a450..ea728ae 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -114,6 +114,15 @@ void ui_set_battery(uint16_t mv, uint8_t pct); */ void ui_set_clock(uint32_t epoch); +/** + * Set the whole-hour offset from UTC used when formatting the on-device + * clock. Display only: the epoch handed to ui_set_clock() stays UTC, and + * nothing here may ever reach RTCClock or a packet timestamp. + * + * @param hours Offset from UTC, TZ_OFFSET_MIN..TZ_OFFSET_MAX (0 = UTC) + */ +void ui_set_tz(int8_t hours); + /** * Record a recently heard contact for the "recent" page. * diff --git a/zephcore/west.yml b/zephcore/west.yml index 8222234..e832e75 100644 --- a/zephcore/west.yml +++ b/zephcore/west.yml @@ -6,7 +6,7 @@ manifest: projects: - name: zephyr remote: zephyrproject-rtos - revision: e01b6413005cd1e23ce97592743ee6990fe7aef0 + revision: 1d9ce23e364176b3ef050fd3b3d6255ca574c330 import: true self: