From 6f7f807e8233ebba1ba0cf5133c4d2c79e97420e Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:01:02 +0200 Subject: [PATCH] upstream dev bump --- docs/ARCHITECTURE.md | 1 + docs/CLI_commands.md | 4 +- releasenotes/RELEASE_NOTES_1.18.1-zephcore.md | 29 +++++++ tests/catalog.json | 15 ++++ tests/unit/prefs_codec_tests.cpp | 64 ++++++++++++++- tests/unit/prefs_json_tests.cpp | 21 +++++ zephcore/CMakeLists.txt | 2 +- zephcore/helpers/CommonCLI.cpp | 20 +++-- zephcore/helpers/NodePrefs.h | 21 +++-- zephcore/helpers/PrefsCodec.cpp | 18 ++++- zephcore/helpers/tz_offset.h | 79 +++++++++++++++++++ zephcore/helpers/ui-button/ui_pages.c | 17 ++-- zephcore/helpers/ui-button/ui_pages.h | 2 +- zephcore/helpers/ui-button/ui_task.c | 6 +- .../helpers/ui-joystick/joystick_ui_hooks.cpp | 2 +- .../helpers/ui-joystick/screens/system.cpp | 14 ++-- zephcore/helpers/ui/ui_headless_stubs.c | 4 +- zephcore/helpers/ui/ui_mesh_actions.cpp | 2 +- zephcore/helpers/ui/ui_task.h | 11 +-- zephcore/src/server_main_common.cpp | 4 + 20 files changed, 278 insertions(+), 58 deletions(-) create mode 100644 releasenotes/RELEASE_NOTES_1.18.1-zephcore.md create mode 100644 zephcore/helpers/tz_offset.h diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a3672d9..b998a6c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,6 +118,7 @@ zephcore/ │ ├── RegionMap.cpp/h # Region-based flood filtering │ ├── ContactInfo.h, ChannelDetails.h, NodePrefs.h # Data structures │ ├── RateLimiter.h, IdentityStore.h, StatsFormatHelper.h +│ ├── tz_offset.h # Display timezone: quarter-hour checks, minutes, CLI/UI text (C and C++) │ ├── battery_curve.c/h, fatal_reboot.c, oled_power.c/h │ ├── ui/ # Shared UI plumbing: display, buzzer, multi-tap input, Doom │ ├── ui-button/ # Single-button page UI (pages, task) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index c2c49a5..98f0333 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -376,7 +376,7 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and, ap | `get rxduty` | *(ZephCore only)* RX duty cycle mode: `0` or `1` | | `get display.rotate` | *(ZephCore only)* 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` | *(ZephCore only)* Joystick/D-pad axis swap: `0` or `1` | -| `get tz.offset` | Display timezone: whole hours from UTC, `-12`..`14` (`0` = UTC) | +| `get tz.offset` | Display timezone: hours from UTC, `-12`..`14` in quarter hours, printed exactly (`0.0` = UTC, `2.0`, `-3.5`, `5.75`) | | `get gps duty` | *(ZephCore only)* Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | | `get gps standby` | *(ZephCore only)* GPS standby threshold in seconds (see `set gps standby`) | | `get gps diag` | *(ZephCore only)* 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 | @@ -459,7 +459,7 @@ four radio parameters together, since they are one interop-critical set. | `set rxduty <0\|1\|on\|off>` | | *(ZephCore only)* 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** | *(ZephCore only)* 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`/`v4_r8`, `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** | *(ZephCore only)* 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 tz.offset <-12..14>` | default **0** (UTC) | Offset from UTC in hours, applied when formatting the **on-device clock display** — the top bar, the status page (companion and server roles) and the joystick System -> Time screen, which show e.g. `UTC+2`, `UTC-3:30` or `UTC+5:45` instead of `UTC` (`UTC+1245` for the two-digit-hour zones +10:30 and +12:45/+13:45, to fit 8 characters). Decimal hours in quarter-hour steps, as upstream MeshCore 1.18+ (`5.5` = +5:30, `5.75` = +5:45, `-9.5` = -9:30); any other fraction is refused (`Error: offset must be whole quarter hours`), where upstream stores it as given. The reply and `get tz.offset` print the stored value exactly (`OK - tz.offset=5.75`; upstream prints one decimal, so `5.8`). 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. 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 | *(ZephCore only)* 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 | *(ZephCore only)* 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/releasenotes/RELEASE_NOTES_1.18.1-zephcore.md b/releasenotes/RELEASE_NOTES_1.18.1-zephcore.md new file mode 100644 index 0000000..9664167 --- /dev/null +++ b/releasenotes/RELEASE_NOTES_1.18.1-zephcore.md @@ -0,0 +1,29 @@ +# ZephCore 1.18.1-zephcore + +Half-hour and 45-minute timezones on the on-screen clock, the clock zone on repeater screens, and a +Zephyr update. + +> [!NOTE] +> A normal upgrade keeps your identity, settings, contacts and phone pairing. + +--- + +## Half-hour timezones on the clock + +`set tz.offset` now takes half and quarter hours, as upstream MeshCore now does (after its 1.18.0): +`set tz.offset 5.5` for India (+5:30), `5.75` for Nepal (+5:45), `-3.5` for Newfoundland. The +display shows the zone as `UTC+5:30`. Values that are not a whole quarter hour are refused, and +`get tz.offset` now always prints at least one decimal (`2.0`, `5.75`). As before, only the +on-screen clock moves; the node's own clock and every timestamp it sends stay UTC. + +Repeaters and room servers with a screen now show their clock in the zone you set. Before, they +accepted `set tz.offset` but kept showing UTC. + +If you go back to an older ZephCore, a half-hour setting reads as the whole hour below it (5.5 +becomes 5). + +## Other changes + +- **Zephyr** updated to `b70dbaf045e`. A GPS receiver that fails to start on the first try is now + really tried again (the two retries did nothing before), and on boards with external flash a + failed start of that flash is retried at the next mount instead of staying failed until reboot. diff --git a/tests/catalog.json b/tests/catalog.json index a149814..fb593d4 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -307,6 +307,11 @@ "name": "Server gps.en: a file without zc.gps_set is upgraded to on once, then honoured", "source": "tests/unit/prefs_json_tests.cpp" }, + { + "id": "UNIT-PJSON-009", + "name": "A quarter-hour timezone survives prefs.json; upstream's float tz_offset loads", + "source": "tests/unit/prefs_json_tests.cpp" + }, { "id": "UNIT-LPP-001", "name": "Values round to the nearest step (3850 mV is 3.85 V, not the library's 3.84)", @@ -442,6 +447,16 @@ "name": "GPS interval: one clamp, and a corrupt value is not turned into always-on", "source": "tests/unit/prefs_codec_tests.cpp" }, + { + "id": "UNIT-PREFS-011", + "name": "Timezone offset: quarter hours accepted, anything else snapped or reset to UTC", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-012", + "name": "Timezone offset text: exact decimal hours and an 8-character clock label", + "source": "tests/unit/prefs_codec_tests.cpp" + }, { "id": "UNIT-RATE-001", "name": "Rate window threshold expiry and reset", diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp index c8f26d8..6423605 100644 --- a/tests/unit/prefs_codec_tests.cpp +++ b/tests/unit/prefs_codec_tests.cpp @@ -29,9 +29,13 @@ const Field kCompanion[] = { F(153, v_battery_alert_mv), F(155, cad_auto), F(156, cad_offset), F(157, probe_interval), F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), - F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), + F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), F(175, wifi_ssid), F(208, wifi_pwd), }; +// tz_offset is float hours in NodePrefs but one signed whole-hours byte in +// both files, so it is checked by value, not by the byte table. +const size_t kCompanionTz = 171; +const size_t kServerTz = 308; // Server /lfs/repeater/prefs, 311 bytes. Offset 120 is leds_disabled as a // magic byte (checked separately); pads at 36-39, 79, 123, 127-151 (bridge), @@ -49,7 +53,7 @@ const Field kServer[] = { F(292, _reserved_apc_enabled), F(293, _reserved_apc_margin), F(294, flood_max_unscoped), F(295, flood_max_advert), F(296, meshtimesync), F(297, cad_auto), F(298, cad_offset), F(299, probe_interval), F(300, cad_busycap), F(301, extra_sf), F(304, fem_rxgain), - F(305, display_rotate), F(306, input_rotate), F(307, cad_base), F(308, tz_offset), + F(305, display_rotate), F(306, input_rotate), F(307, cad_base), F(309, leds_radio_mode), F(310, leds_hb_mode), }; #undef F @@ -131,6 +135,7 @@ TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the do std::memset(buf, 0xEE, sizeof(buf)); CHECK(companionPrefsEncode(p, buf, sizeof(buf)) == COMPANION_PREFS_SIZE); checkLayout(buf, p, kCompanion); + CHECK((int8_t)buf[kCompanionTz] == -5); CHECK(zeros(buf, 36, 40)); CHECK(buf[79] == 0); CHECK(buf[COMPANION_PREFS_SIZE] == 0xEE); @@ -159,6 +164,7 @@ TEST(prefs_companion_short, "UNIT-PREFS-003", "Short companion files keep the ca NodePrefs got = defaults(); CHECK(companionPrefsDecode(got, full, len)); NodePrefs want = expectShort(p, len, kCompanion); + if (len > kCompanionTz) want.tz_offset = p.tz_offset; uint8_t g[COMPANION_PREFS_SIZE], w[COMPANION_PREFS_SIZE]; companionPrefsEncode(got, g, sizeof(g)); companionPrefsEncode(want, w, sizeof(w)); @@ -221,6 +227,7 @@ TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the document std::memset(buf, 0xEE, sizeof(buf)); CHECK(serverPrefsEncode(p, buf, sizeof(buf)) == SERVER_PREFS_SIZE); checkLayout(buf, p, kServer); + CHECK((int8_t)buf[kServerTz] == -5); CHECK(buf[120] == LEDS_PREF_OFF); CHECK(zeros(buf, 36, 40)); CHECK(buf[79] == 0 && buf[123] == 0); @@ -254,6 +261,7 @@ TEST(prefs_server_short, "UNIT-PREFS-008", "Short server files keep the caller's serverPrefsDecode(got, full, len); NodePrefs want = expectShort(p, len, kServer); want.leds_disabled = p.leds_disabled; // offset 120, inside every length + if (len > kServerTz) want.tz_offset = p.tz_offset; uint8_t g[SERVER_PREFS_SIZE], w[SERVER_PREFS_SIZE]; serverPrefsEncode(got, g, sizeof(g)); serverPrefsEncode(want, w, sizeof(w)); @@ -297,3 +305,55 @@ TEST(prefs_gps_interval, "UNIT-PREFS-010", "GPS interval: one clamp, and a corru sanitizeNodePrefs(&p); CHECK(p.gps_interval == GPS_INTERVAL_MAX_SEC); } + +TEST(prefs_tz_quarter_hours, "UNIT-PREFS-011", "Timezone offset: quarter hours accepted, anything else snapped or reset to UTC") { + CHECK(tz_offset_valid(0.0f) && tz_offset_valid(5.5f) && tz_offset_valid(5.75f)); + CHECK(tz_offset_valid(-3.5f) && tz_offset_valid(-12.0f) && tz_offset_valid(14.0f)); + CHECK(tz_offset_valid(12.75f) && tz_offset_valid(-9.5f)); + CHECK(!tz_offset_valid(5.3f) && !tz_offset_valid(5.249f) && !tz_offset_valid(-0.1f)); + CHECK(!tz_offset_valid(-12.25f) && !tz_offset_valid(14.25f) && !tz_offset_valid(NAN)); + CHECK(tz_offset_sanitize(5.3f) == 5.25f); + CHECK(tz_offset_sanitize(-3.4f) == -3.5f); + CHECK(tz_offset_sanitize(15.0f) == 0.0f && tz_offset_sanitize(-13.0f) == 0.0f); + CHECK(tz_offset_sanitize(NAN) == 0.0f); + CHECK(tz_offset_minutes(5.75f) == 345 && tz_offset_minutes(-3.5f) == -210); + CHECK(tz_offset_minutes(14.0f) == 840 && tz_offset_minutes(-12.0f) == -720); + NodePrefs p; initNodePrefs(&p); + CHECK(p.tz_offset == 0.0f); + p.tz_offset = NAN; + sanitizeNodePrefs(&p); + CHECK(p.tz_offset == 0.0f); + p.tz_offset = 9.6f; + sanitizeNodePrefs(&p); + CHECK(p.tz_offset == 9.5f); + // The binary layouts hold whole hours: a fractional offset truncates toward zero. + p = sample(); + p.tz_offset = -9.5f; + uint8_t c[COMPANION_PREFS_SIZE], s[SERVER_PREFS_SIZE]; + companionPrefsEncode(p, c, sizeof(c)); + serverPrefsEncode(p, s, sizeof(s)); + CHECK((int8_t)c[kCompanionTz] == -9 && (int8_t)s[kServerTz] == -9); + NodePrefs q = defaults(); + CHECK(companionPrefsDecode(q, c, sizeof(c))); + CHECK(q.tz_offset == -9.0f); + q = defaults(); + serverPrefsDecode(q, s, sizeof(s)); + CHECK(q.tz_offset == -9.0f); +} + +TEST(prefs_tz_text, "UNIT-PREFS-012", "Timezone offset text: exact decimal hours and an 8-character clock label") { + struct { int16_t min; const char *hours; const char *label; } cases[] = { + { 0, "0.0", "UTC" }, { 120, "2.0", "UTC+2" }, { -300, "-5.0", "UTC-5" }, + { 330, "5.5", "UTC+5:30" }, { 345, "5.75", "UTC+5:45" }, { -210, "-3.5", "UTC-3:30" }, + { -30, "-0.5", "UTC-0:30" }, { 765, "12.75", "UTC+1245" }, { 630, "10.5", "UTC+1030" }, + { 840, "14.0", "UTC+14" }, { -720, "-12.0", "UTC-12" }, { 525, "8.75", "UTC+8:45" }, + }; + for (const auto &t : cases) { + char h[TZ_OFFSET_TEXT_LEN], l[TZ_OFFSET_TEXT_LEN]; + tz_offset_format_hours(t.min, h, sizeof(h)); + tz_offset_format_label(t.min, l, sizeof(l)); + if (std::strcmp(h, t.hours) != 0 || std::strcmp(l, t.label) != 0) + throw std::runtime_error(std::to_string(t.min) + " -> " + h + " / " + l); + CHECK(std::strlen(l) <= 8); + } +} diff --git a/tests/unit/prefs_json_tests.cpp b/tests/unit/prefs_json_tests.cpp index 1c5a8d2..bedec16 100644 --- a/tests/unit/prefs_json_tests.cpp +++ b/tests/unit/prefs_json_tests.cpp @@ -195,3 +195,24 @@ TEST(pjson_server_gps_upgrade, "UNIT-PJSON-008", "Server gps.en: a file without CHECK(companionPrefsFromJson(c, comp)); CHECK(c.gps_enabled == 0); } + +TEST(pjson_tz_fraction, "UNIT-PJSON-009", "A quarter-hour timezone survives prefs.json; upstream's float tz_offset loads") { + NodePrefs p = sample(); + p.tz_offset = 5.75f; + MemStream s; + CHECK(companionPrefsToJson(p, s)); + NodePrefs q = defaults(); + CHECK(companionPrefsFromJson(q, s)); + CHECK(q.tz_offset == 5.75f); + p.tz_offset = -9.5f; + MemStream t; + CHECK(serverPrefsToJson(p, t)); + q = defaults(); + CHECK(serverPrefsFromJson(q, t)); + CHECK(q.tz_offset == -9.5f); + // Upstream v1.18+ writes the float with four decimals. + MemStream u("{comp:{tz_offset:5.5000}}"); + q = defaults(); + CHECK(companionPrefsFromJson(q, u)); + CHECK(q.tz_offset == 5.5f); +} diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 733a206..4ad6851 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -33,7 +33,7 @@ list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) # Service advertises (CONFIG_BT_DIS_FW_REV_STR, generated from it below), what the # GitHub release is tagged/named, and what the Mesh America catalog uses as its # version key. The release workflow reads this line directly — keep its form. -set(ZEPHCORE_FIRMWARE_VERSION "1.18.0-zephcore") +set(ZEPHCORE_FIRMWARE_VERSION "1.18.1-zephcore") # ========== Helpers ========== # Append FILE to the overlay list once, keeping its FIRST position. Position is diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index f81ce9e..87391b8 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -1125,7 +1125,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha } 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); + char hours[TZ_OFFSET_TEXT_LEN]; + tz_offset_format_hours(tz_offset_minutes(_prefs->tz_offset), hours, sizeof(hours)); + snprintf(reply, CLI_REPLY_SIZE, "> %s", hours); } else if (memcmp(config, "gps diag", 8) == 0) { /* What the last module-configuration attempt actually did. * Self-limits on the remote path like cad.stats/meshtimesync: @@ -1714,19 +1716,23 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha 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 ` stay UTC. Whole hours as upstream. - long val; - if (!cliNum(&config[10], cliDefaults()->tz_offset, &val)) { + // Hours from UTC in quarter-hour steps, for the on-device clock display + // only; the RTC, `clock` and `time ` stay UTC. + float val; + if (!cliFloat(&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 if (!tz_offset_valid(val)) { + strcpy(reply, "Error: offset must be whole quarter hours (e.g. 5.5, 5.75)"); } else { - _prefs->tz_offset = (int8_t)val; + char hours[TZ_OFFSET_TEXT_LEN]; + _prefs->tz_offset = tz_offset_sanitize(val); savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - tz.offset=%d", (int)val); + tz_offset_format_hours(tz_offset_minutes(_prefs->tz_offset), hours, sizeof(hours)); + snprintf(reply, CLI_REPLY_SIZE, "OK - tz.offset=%s", hours); } } else if (memcmp(config, "gps diag", 8) == 0) { // set gps diag <0|1|on|off> — arm module-configuration reporting. diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 84af596..b22dcc9 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -17,6 +17,9 @@ * and this header is C++; see the note there. */ #include "led_gate.h" +/* TZ_OFFSET_MIN / TZ_OFFSET_MAX and the quarter-hour helpers for tz_offset. */ +#include "tz_offset.h" + /* ── Accepted radio parameter ranges ── * One definition for everything that accepts or validates a radio preset * (CLIs, the companion protocol, the load-time checks): a setter must never @@ -62,11 +65,6 @@ * 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; @@ -149,10 +147,10 @@ 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. */ - int8_t tz_offset; + /* Hours from UTC, -12..+14 in quarter hours (float, as upstream), applied + * only when formatting a clock for the local display. It must never reach + * RTCClock or any timestamp that leaves this node. See tz_offset.h. */ + float tz_offset; /* The family base detPeak that cad_offset was learned against (0 = never * stored). Lets the firmware re-anchor the offset at boot when the driver's @@ -354,8 +352,9 @@ 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; + /* UTC, not a boundary zone, for a value that carries no user intent; + * an in-range value snaps to the nearest quarter hour. */ + p->tz_offset = tz_offset_sanitize(p->tz_offset); 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/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index a22984d..9f35297 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -17,6 +17,9 @@ struct Wr { size_t off; void put(const void *src, size_t n) { memcpy(b + off, src, n); off += n; } void zeros(size_t n) { memset(b + off, 0, n); off += n; } + /* tz_offset: both layouts hold signed whole hours in one byte, so a + * fractional offset is truncated toward zero. */ + void tz(float hours) { int8_t v = (int8_t)tz_offset_sanitize(hours); put(&v, 1); } }; struct Rd { @@ -36,6 +39,13 @@ struct Rd { } off += n; } + /* The one-byte whole-hours tz_offset into the float field. */ + void tz(float &hours) { + if (off < len) { + hours = (int8_t)b[off]; + } + off += 1; + } }; } // namespace @@ -103,7 +113,7 @@ size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) w.put(&p.display_rotate, 1); w.put(&p.input_rotate, 1); w.put(&p.cad_base, 1); - w.put(&p.tz_offset, 1); + w.tz(p.tz_offset); w.put(&p.leds_radio_mode, 1); w.put(&p.leds_hb_mode, 1); w.put(&p.wifi_enabled, 1); @@ -184,7 +194,7 @@ bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) /* cad_base 0 = "no base recorded": setCadParams() then leaves the stored * offset alone. */ r.take(&p.cad_base, 1); - r.take(&p.tz_offset, 1); + r.tz(p.tz_offset); r.take(&p.leds_radio_mode, 1); r.take(&p.leds_hb_mode, 1); r.take(&p.wifi_enabled, 1); @@ -276,7 +286,7 @@ size_t serverPrefsEncode(const NodePrefs &prefs, uint8_t *buf, size_t cap) w.put(&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 */ - w.put(&prefs.tz_offset, sizeof(prefs.tz_offset)); + w.tz(prefs.tz_offset); /* LED activity/heartbeat modes (offsets 309-310) */ w.put(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); w.put(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); @@ -352,7 +362,7 @@ void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) * offset alone, since a node upgrading across a table change cannot know * which base its offset came from. */ r.get(&prefs.cad_base, sizeof(prefs.cad_base)); - r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); + r.tz(prefs.tz_offset); r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); diff --git a/zephcore/helpers/tz_offset.h b/zephcore/helpers/tz_offset.h new file mode 100644 index 0000000..30f9919 --- /dev/null +++ b/zephcore/helpers/tz_offset.h @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: MIT + * Display timezone offset: validation and text, shared by the CLI, both UIs + * and the prefs loaders (C and C++). + * + * NodePrefs::tz_offset holds float hours from UTC, upstream MeshCore's type and + * prefs.json value. ZephCore accepts quarter hours only and every consumer + * works in whole minutes. The offset is applied only when a clock is formatted + * for the local display; it never reaches RTCClock or a timestamp. + */ + +#pragma once + +#include +#include +#include +#include + +/* Range in hours, as upstream's `set tz.offset`. */ +#define TZ_OFFSET_MIN (-12) +#define TZ_OFFSET_MAX 14 + +/* Buffer size for tz_offset_format_hours() and tz_offset_format_label(). */ +#define TZ_OFFSET_TEXT_LEN 12 + +/* Nearest quarter hour; 0 (UTC) for NaN or a value outside the range. */ +static inline float tz_offset_sanitize(float hours) +{ + if (hours != hours || hours < TZ_OFFSET_MIN || hours > TZ_OFFSET_MAX) { + return 0.0f; + } + float q = hours * 4.0f; + return (float)(int32_t)(q < 0.0f ? q - 0.5f : q + 0.5f) * 0.25f; +} + +/* In range and a whole number of quarter hours (within 0.0001 h). */ +static inline bool tz_offset_valid(float hours) +{ + if (hours != hours || hours < TZ_OFFSET_MIN || hours > TZ_OFFSET_MAX) { + return false; + } + float d = hours - tz_offset_sanitize(hours); + return d > -0.0001f && d < 0.0001f; +} + +/* Offset in whole minutes, from a sanitized value. */ +static inline int16_t tz_offset_minutes(float hours) +{ + float m = tz_offset_sanitize(hours) * 60.0f; + return (int16_t)(m < 0.0f ? m - 0.5f : m + 0.5f); +} + +/* Decimal hours, exact for every quarter hour: "0.0", "2.0", "-3.5", "5.75". + * Same text as upstream's "%.1f" wherever that is exact. */ +static inline void tz_offset_format_hours(int16_t minutes, char *buf, size_t n) +{ + static const char *const frac[4] = { "0", "25", "5", "75" }; + unsigned a = (unsigned)(minutes < 0 ? -minutes : minutes); + + snprintf(buf, n, "%s%u.%s", minutes < 0 ? "-" : "", a / 60, frac[(a % 60) / 15]); +} + +/* Zone label for a clock display, at most 8 characters: "UTC", "UTC+2", + * "UTC-3:30", and "UTC+1245" where the h:mm form would be longer. */ +static inline void tz_offset_format_label(int16_t minutes, char *buf, size_t n) +{ + unsigned a = (unsigned)(minutes < 0 ? -minutes : minutes); + char sign = minutes < 0 ? '-' : '+'; + + if (a == 0) { + snprintf(buf, n, "UTC"); + } else if (a % 60 == 0) { + snprintf(buf, n, "UTC%c%u", sign, a / 60); + } else if (a / 60 < 10) { + snprintf(buf, n, "UTC%c%u:%02u", sign, a / 60, a % 60); + } else { + snprintf(buf, n, "UTC%c%02u%02u", sign, a / 60, a % 60); + } +} diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 30ded3f..f9e2a75 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -19,6 +19,7 @@ #include "ui_mesh_actions.h" #include "display.h" #include +#include #include #include @@ -200,19 +201,17 @@ static int current_page_idx; * 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; + return state.rtc_epoch + (int32_t)state.tz_offset_min * 60; } -/* The zone the displayed digits are actually in: "UTC", "UTC+2", "UTC-11". +/* The zone the displayed digits are actually in: "UTC", "UTC+2", "UTC-3:30", + * at most 8 characters (tz_offset_format_label()). * Static buffer -- every caller is on the UI thread inside ui_pages_render(). */ static const char *tz_label(void) { - static char label[8]; + static char label[TZ_OFFSET_TEXT_LEN]; - if (state.tz_offset == 0) { - return "UTC"; - } - snprintf(label, sizeof(label), "UTC%+d", (int)state.tz_offset); + tz_offset_format_label(state.tz_offset_min, label, sizeof(label)); return label; } @@ -1713,8 +1712,8 @@ static void render_status(void) uint8_t mm = (day_sec % 3600) / 60; uint8_t ss = day_sec % 60; - /* "T:" rather than "Time:" so a two-digit zone still fits the - * 20-column budget of the 200x200 e-paper boards, which pair + /* "T:" rather than "Time:" so an 8-character zone label 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()); diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index 4cf06d5..6622f03 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -45,7 +45,7 @@ struct ui_state { uint16_t battery_mv; uint8_t battery_pct; 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 + int16_t tz_offset_min; /* minutes from UTC, applied at format time * only -- rtc_epoch itself is never shifted */ /* Messages page */ diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index b80ba63..d115a66 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -1035,14 +1035,14 @@ void ui_set_clock(uint32_t epoch) } } -void ui_set_tz(int8_t hours) +void ui_set_tz(int16_t minutes) { struct ui_state *s = get_state(); - if (s->tz_offset == hours) { + if (s->tz_offset_min == minutes) { return; } - s->tz_offset = hours; + s->tz_offset_min = minutes; /* EPD only redraws on demand, so a timezone change would otherwise not * appear until something else forced a repaint. */ diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index 831ba01..0936709 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -260,7 +260,7 @@ extern "C" void ui_set_ble_enabled(bool enabled) 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_set_tz(int16_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 2de07ca..fc76208 100644 --- a/zephcore/helpers/ui-joystick/screens/system.cpp +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -349,25 +349,21 @@ int SystemTimeScreen::render(JoystickDisplay &display) /* 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; + int16_t tz_min = prefs ? tz_offset_minutes(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; + epoch += (int32_t)tz_min * 60; } else { - tz = 0; + tz_min = 0; } - char timeText[16], dateText[16], tzText[8]; + char timeText[16], dateText[16], tzText[TZ_OFFSET_TEXT_LEN]; formatUnixDateTime(epoch, timeText, sizeof(timeText), dateText, sizeof(dateText)); - if (tz == 0) { - strcpy(tzText, "UTC"); - } else { - snprintf(tzText, sizeof(tzText), "UTC%+d", (int)tz); - } + tz_offset_format_label(tz_min, tzText, sizeof(tzText)); const char *labels[4] = { "Time:", "Date:", "TZ:", "Source:" }; const char *values[4] = { timeText, dateText, tzText, diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index 525fd62..840046e 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -77,9 +77,9 @@ WEAK void ui_set_clock(uint32_t epoch) ARG_UNUSED(epoch); } -WEAK void ui_set_tz(int8_t hours) +WEAK void ui_set_tz(int16_t minutes) { - ARG_UNUSED(hours); + ARG_UNUSED(minutes); } WEAK void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s) diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index ebd12be..5780f52 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -352,7 +352,7 @@ extern "C" void mesh_housekeeping_ui_refresh(void) * 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_tz(tz_offset_minutes(s_mesh->prefs.tz_offset)); ui_push_radio_state(*s_lora_radio); /* The app and the CLI change it too. */ diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index fbb5d7b..5c6dfc1 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -119,13 +119,14 @@ 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. + * Set the 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) + * @param minutes Offset from UTC in minutes, a multiple of 15 within + * TZ_OFFSET_MIN..TZ_OFFSET_MAX hours (0 = UTC) */ -void ui_set_tz(int8_t hours); +void ui_set_tz(int16_t minutes); /** * Record a recently heard contact for the "recent" page. diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index b4a622f..3dc0401 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -563,6 +563,10 @@ static void server_event_loop(void) #if ZEPHCORE_HAS_UI ui_set_clock(rtc_clock.getCurrentTime()); + /* Display offset only; the epoch above stays UTC. */ + if (server_role) { + ui_set_tz(tz_offset_minutes(server_role->prefs->tz_offset)); + } #ifdef ZEPHCORE_LORA /* Refresh live radio state (noise floor, TX power