diff --git a/.editorconfig b/.editorconfig index 1b4c822..549a265 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,7 +48,7 @@ indent_size = unset # changes, each fenced `// ZEPHCORE:`. Upstream mixes 2-space and tab indentation # and has trailing whitespace, so none of it is enforced here. Add a file to this # list when it is converted to upstream shape. -[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h,zephcore/helpers/BaseChatMesh.cpp,zephcore/helpers/BaseChatMesh.h,zephcore/helpers/ContactInfo.h,zephcore/helpers/ChannelDetails.h,zephcore/helpers/ClientACL.cpp,zephcore/helpers/ClientACL.h,zephcore/helpers/RegionMap.cpp,zephcore/helpers/RegionMap.h,zephcore/helpers/TransportKeyStore.cpp,zephcore/helpers/TransportKeyStore.h,zephcore/helpers/BaseSerialInterface.h,zephcore/helpers/MultiSerialInterface.h,zephcore/helpers/ConfigSerializer.cpp,zephcore/helpers/ConfigSerializer.h,zephcore/helpers/SensorManager.h}] +[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h,zephcore/helpers/BaseChatMesh.cpp,zephcore/helpers/BaseChatMesh.h,zephcore/helpers/ContactInfo.h,zephcore/helpers/ChannelDetails.h,zephcore/helpers/ClientACL.cpp,zephcore/helpers/ClientACL.h,zephcore/helpers/RegionMap.cpp,zephcore/helpers/RegionMap.h,zephcore/helpers/TransportKeyStore.cpp,zephcore/helpers/TransportKeyStore.h,zephcore/helpers/BaseSerialInterface.h,zephcore/helpers/MultiSerialInterface.h,zephcore/helpers/ConfigSerializer.cpp,zephcore/helpers/ConfigSerializer.h,zephcore/helpers/SensorManager.h,zephcore/helpers/sensors/LPPDataHelpers.h}] indent_style = unset indent_size = unset tab_width = unset diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83e70cc..1de952f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -699,9 +699,18 @@ restart-reason message partially compensates) and bounded by threshold + 200 mV (so the alert wins the race against the 90 s shutdown confirm window), 3500 mV on boards without auto-shutdown. Alert latches once per discharge cycle; re-arms on external power, recovery above - threshold + 150 mV, or threshold change. Sampling mirrors - `ui_auto_shutdown_check()` (30 s gate, 3-strike confirm) but lives in - `main_companion.cpp` so headless builds alert too. + threshold + 150 mV, or threshold change. + +**Low-battery policy** (`app/PowerPolicy.cpp`, every companion, UI or headless): +the v-contact alert above and auto-shutdown (`get|set autoshutdown `, 0 = +off; default `CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS`, **3200 mV on nRF52**, 0 +elsewhere because ESP32 cannot tell USB power from battery). Both sample every +30 s on the housekeeping tick, act on the third low reading in a row, skip on +external power, and treat anything under 2000 mV as "no battery" (a board without +a cell reads 0). A shutdown with an app connected sends a v-contact notice and +waits 1 s; otherwise it stores the reason (`Low Voltage`) for the next boot. The +UI only draws the warning (`ui_show_low_battery()`). A stored 3300 mV (the old +default) is migrated to 3200 once (`zc.aoff_set`). ### 6.3 RepeaterMesh @@ -729,6 +738,7 @@ Config: `set name/freq/radio/tx/flood.max/password/...`, corresponding getters System (power): `poweroff`/`shutdown`, `get pwrmgt.support/source/bootreason/bootmv` GPS: `gps`, `gps on/off/sync/setloc/advert`, `get gps`, `set gps duty ` Sensors: `sensor get/set/list` (upstream's `SensorManager` settings: `gps`) +Companion extras: `get|set autoshutdown`, `get|set v.contact`, `get|set v.batteryalert`, `help` Stats: `stats-core/stats-radio/stats-packets`, `clear stats` Time: `clock`, `clock sync`, `time `, `set meshtimesync on/off` @@ -857,12 +867,14 @@ frame (a reply to one app also reaches the other). ### 8.1 Architecture -Event-driven, no dedicated thread. All UI work on Zephyr work queues. +Event-driven, no dedicated thread. -Two UI frontends share the same plumbing (`helpers/ui/`: display, buzzer, multi-tap input filter, mesh action queue): +Two UI frontends share the same plumbing (`helpers/ui/`: display, buzzer, multi-tap input filter, mesh action queue, clock-source tag `time_sync.c`, `ui_radio_state.h`, `telemetry_text.cpp`): -- **Button UI** (`helpers/ui-button/`): single-button page cycler — most boards -- **Joystick UI** (`helpers/ui-joystick/`): 5-way joystick menu UI (Wio Tracker L1) +- **Button UI** (`helpers/ui-button/`): single-button page cycler — most boards, every role. Renders on the system work queue; changes mesh state only through `ui_mesh_actions` (posted, applied on the main thread). +- **Joystick UI** (`helpers/ui-joystick/`): 5-way joystick / keypad menu UI, companion only (Wio Tracker L1, Wio L1 1W, GAT562-30S, ThinkNode M9). Keys go through a queue to `JoystickUITask::loop()` **on the main thread**, so its direct `CompanionMesh` calls (through `getMesh()`) are safe. `screens/repeater_admin.cpp` is the remote admin screen. + +Battery percentage on both UIs is the board's own (`ZephyrBoard::getBattPercent()`: discharge curve, or the fuel gauge). Low-battery policy is not UI: see `app/PowerPolicy.cpp` (the UI only draws `ui_show_low_battery()`). ``` Hardware buttons → Zephyr input subsystem → Longpress filter → Multi-tap filter diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index ab82038..15f7f4e 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -604,6 +604,7 @@ else() helpers/ui/ui_mesh_actions.cpp app/CompanionMesh.cpp app/VContact.cpp + app/PowerPolicy.cpp # CommonCLI backs the v-contact loopback admin chat (companion_cli_exec in # main_companion.cpp), which is NOT gated on the wired-USB stack — so it # must be compiled unconditionally. Builds without CONFIG_LOG/COMPANION_USB/ @@ -718,7 +719,7 @@ endif() if(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) target_sources(app PRIVATE helpers/ui-button/ui_task.c - helpers/ui-button/time_sync.c + helpers/ui/time_sync.c ) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui-button @@ -733,11 +734,13 @@ endif() if(CONFIG_ZEPHCORE_UI_DISPLAY AND CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) target_sources(app PRIVATE - helpers/ui-joystick/time_sync.c + helpers/ui/time_sync.c helpers/ui-joystick/joystick_ui_task.cpp helpers/ui-joystick/joystick_ui_hooks.cpp helpers/ui-joystick/screens/home.cpp helpers/ui-joystick/screens/contacts.cpp + helpers/ui-joystick/screens/repeater_admin.cpp + helpers/ui/telemetry_text.cpp helpers/ui-joystick/screens/messaging.cpp helpers/ui-joystick/screens/tools.cpp helpers/ui-joystick/screens/system.cpp diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 77ecca8..2440c8a 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -1125,7 +1125,7 @@ config ZEPHCORE_UI_TIME_SOURCE_FRESH_HOURS config ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS int "Low-battery auto-shutdown threshold (mV, 0 = disabled)" - default 3300 if SOC_SERIES_NRF52 + default 3200 if SOC_SERIES_NRF52 default 0 help Companion only. When the battery falls below this voltage the device diff --git a/zephcore/adapters/board/ZephyrBoard.h b/zephcore/adapters/board/ZephyrBoard.h index cedc89e..368e5dd 100644 --- a/zephcore/adapters/board/ZephyrBoard.h +++ b/zephcore/adapters/board/ZephyrBoard.h @@ -14,9 +14,9 @@ public: /* One ADC burst (or fuel-gauge read) serves every caller for * BATT_CACHE_MS: UI, telemetry, stats, alerts. Any thread. */ uint16_t getBattMilliVolts() override; - /* The board's discharge curve (or the fuel gauge's state of charge). - * Not a MainBoard method: nothing upstream-shaped needs it. The UIs - * still use a linear 3.0-4.2 V estimate (slice 11). */ + /* The board's discharge curve (or the fuel gauge's state of charge); + * what both UIs display. Not a MainBoard method: nothing + * upstream-shaped needs it. */ uint8_t getBattPercent(); float getMCUTemperature() override; bool setAdcMultiplier(float multiplier) override; diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 00244a8..c45227b 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -430,6 +430,9 @@ bool ZephyrDataStore::loadLegacyPrefs(NodePrefs &prefs) (double)freq, (int)buf[60], (double)bw, PREFS_FILE); return false; } + /* That firmware's 3300 mV default is 3200 now (NodePrefs.h). */ + prefs.auto_shutdown_set = 0; + auto_shutdown_upgrade(&prefs); return true; } diff --git a/zephcore/app/PowerPolicy.cpp b/zephcore/app/PowerPolicy.cpp new file mode 100644 index 0000000..e58be02 --- /dev/null +++ b/zephcore/app/PowerPolicy.cpp @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: MIT + * See PowerPolicy.h. + */ + +#include "PowerPolicy.h" + +#include +#include + +#include +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_power_policy, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && \ + CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0 +#define HAS_AUTO_SHUTDOWN 1 +#else +#define HAS_AUTO_SHUTDOWN 0 +#endif + +#define CHECK_INTERVAL_MS 30000 /* one battery reading per 30 s */ +#define CONFIRM_COUNT 3 /* 3 x 30 s: a TX sag cannot trigger either */ +#define ALERT_REARM_MV 150 /* the alert re-arms this far above its threshold */ + +/* With an app connected, the power-off waits this long so the notice's + * notify -> fetch -> send round-trip can finish. */ +#define SHUTDOWN_GRACE_MS 1000 + +#if HAS_AUTO_SHUTDOWN +static void shutdown_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + zephcore_power_off(); +} +static K_WORK_DELAYABLE_DEFINE(s_shutdown_work, shutdown_work_fn); +#endif + +void PowerPolicy::tick() +{ + if (_shutting_down) { + return; /* committed; the grace work powers off */ + } + uint32_t now = k_uptime_get_32(); + + if (_checked && (now - _next_check_ms) < CHECK_INTERVAL_MS) { + return; + } + _checked = true; + _next_check_ms = now; + + uint16_t mv = _board.getBattMilliVolts(); + + if (mv < NO_CELL_MV) { + /* No battery (or no battery ADC): nothing to protect, nothing to say. */ + _alert_low = 0; + _shutdown_low = 0; + return; + } + bool on_battery = !_board.isExternalPowered(); + + checkAlert(mv, on_battery); + checkShutdown(mv, on_battery); +} + +uint16_t PowerPolicy::alertThresholdMv() const +{ + uint16_t pref = _prefs.v_battery_alert_mv; + + if (pref != 0xFFFF) { + return pref; + } + uint16_t cutoff = HAS_AUTO_SHUTDOWN ? _prefs.auto_shutdown_mv : 0; + + return cutoff ? (uint16_t)(cutoff + 200) : 3500; +} + +void PowerPolicy::checkAlert(uint16_t mv, bool on_battery) +{ + uint16_t thresh = alertThresholdMv(); + + if (thresh == 0 || !_hooks.alert_enabled || !_hooks.alert_enabled()) { + return; + } + if (!on_battery || mv >= thresh + ALERT_REARM_MV) { + rearmAlert(); /* charging or recovered: ready for the next discharge */ + return; + } + if (mv >= thresh) { + _alert_low = 0; + return; + } + if (_alert_latched || ++_alert_low < CONFIRM_COUNT) { + return; + } + _alert_latched = true; + if (_hooks.battery_alert) { + _hooks.battery_alert(mv, thresh); + } +} + +void PowerPolicy::checkShutdown(uint16_t mv, bool on_battery) +{ +#if HAS_AUTO_SHUTDOWN + uint16_t cutoff = _prefs.auto_shutdown_mv; + + if (cutoff == 0 || mv >= cutoff) { + _shutdown_low = 0; + return; + } + /* The reading is the cell, not the supply: never cut a node on a bench + * cable or a charger. */ + if (!on_battery) { + LOG_INF("auto-shutdown: %u mV below %u mV but externally powered", mv, cutoff); + _shutdown_low = 0; + return; + } + LOG_WRN("auto-shutdown: battery %u mV < %u mV (%u/%u)", mv, cutoff, + _shutdown_low + 1, CONFIRM_COUNT); + if (++_shutdown_low < CONFIRM_COUNT) { + return; + } + + LOG_WRN("auto-shutdown: confirmed, powering off"); + _shutting_down = true; + bool grace = _hooks.notify_shutdown && _hooks.notify_shutdown(); + + if (!grace) { + /* Nobody heard it live: the next boot reports it. */ + zephcore_shutdown_reason_save(ZC_SHUTDOWN_LOW_VOLTAGE); + } +#ifdef CONFIG_POWEROFF + if (grace) { + ui_show_low_battery(false); /* draw only: the main loop must deliver */ + k_work_schedule(&s_shutdown_work, K_MSEC(SHUTDOWN_GRACE_MS)); + return; + } + ui_show_low_battery(true); + zephcore_power_off(); +#else + ui_show_low_battery(true); + LOG_WRN("auto-shutdown: CONFIG_POWEROFF not enabled, cannot power off"); +#endif +#else + ARG_UNUSED(mv); + ARG_UNUSED(on_battery); +#endif +} + +bool PowerPolicy::handleCommand(const char *line, char *reply, size_t cap) +{ +#if HAS_AUTO_SHUTDOWN + if (strcmp(line, "get autoshutdown") == 0) { + uint16_t mv = _prefs.auto_shutdown_mv; + + if (mv == 0) { + snprintf(reply, cap, "autoshutdown: off"); + } else { + snprintf(reply, cap, "autoshutdown: %u mV", mv); + } + return true; + } + if (strncmp(line, "set autoshutdown ", 17) == 0) { + const char *arg = line + 17; + + while (*arg == ' ') { + arg++; + } + /* Digits only, then nothing but whitespace. */ + char *end = NULL; + long v = strtol(arg, &end, 10); + + while (*end == ' ' || *end == '\r' || *end == '\n' || *end == '\t') { + end++; + } + if (arg[0] < '0' || arg[0] > '9' || *end != '\0') { + snprintf(reply, cap, "ERROR: numbers only (0 = off, 1-5000 mV)"); + return true; + } + if (v > 5000) { + snprintf(reply, cap, "ERROR: must be 0 (off) or 1-5000 mV"); + return true; + } + _prefs.auto_shutdown_mv = (uint16_t)v; + _prefs.auto_shutdown_set = 1; + if (_hooks.prefs_dirty) { + _hooks.prefs_dirty(); + } + _shutdown_low = 0; + if (v == 0) { + snprintf(reply, cap, "OK - autoshutdown off"); + } else { + snprintf(reply, cap, "OK - autoshutdown %ld mV", v); + } + return true; + } +#else + ARG_UNUSED(line); + ARG_UNUSED(reply); + ARG_UNUSED(cap); +#endif + return false; +} diff --git a/zephcore/app/PowerPolicy.h b/zephcore/app/PowerPolicy.h new file mode 100644 index 0000000..0259f7a --- /dev/null +++ b/zephcore/app/PowerPolicy.h @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: MIT + * + * The companion's low-battery policy, on every build that reads a battery, + * with or without a UI: auto-shutdown (prefs.auto_shutdown_mv, `get|set + * autoshutdown`) and the v-contact low-battery alert (prefs.v_battery_alert_mv). + * Driven by the housekeeping tick, main thread only. + * + * Both sample the battery every 30 s and need three low readings in a row, so + * a TX sag cannot trigger them; both skip while the board reports external + * power. A reading under PowerPolicy::NO_CELL_MV is "no battery" (a board + * without a cell reads 0, or a floating divider), never a reason to act. + */ + +#pragma once + +#include +#include +#include +#include + +class PowerPolicy { +public: + /* Below this no Li-ion cell is running the node: no battery, not a low one. */ + static constexpr uint16_t NO_CELL_MV = 2000; + + struct Hooks { + /* An app should hear about a low-battery shutdown now: send the notice + * live and return true (the power-off then waits a short grace for + * the app to fetch it), or return false (none connected). */ + bool (*notify_shutdown)(void); + /* The alert threshold was crossed (once per discharge). */ + void (*battery_alert)(uint16_t mv, uint16_t threshold_mv); + /* The alert is wanted at all (the v-contact is on). */ + bool (*alert_enabled)(void); + /* prefs changed off the save path: coalesced write. */ + void (*prefs_dirty)(void); + }; + + PowerPolicy(mesh::MainBoard &board, NodePrefs &prefs, const Hooks &hooks) + : _board(board), _prefs(prefs), _hooks(hooks) {} + + /* Every housekeeping tick; self-throttled to one reading per 30 s. */ + void tick(); + + /* `get|set autoshutdown`; true if the line was one. */ + bool handleCommand(const char *line, char *reply, size_t cap); + + /* The alert threshold in mV, 0 = off. The 0xFFFF default is 200 mV above + * the auto-shutdown cutoff, so the alert comes before the shutdown; 3500 mV + * without a cutoff. */ + uint16_t alertThresholdMv() const; + /* The alert threshold changed: arm it again. */ + void rearmAlert() + { + _alert_latched = false; + _alert_low = 0; + } + +private: + void checkAlert(uint16_t mv, bool on_battery); + void checkShutdown(uint16_t mv, bool on_battery); + + mesh::MainBoard &_board; + NodePrefs &_prefs; + Hooks _hooks; + uint32_t _next_check_ms = 0; + bool _checked = false; + uint8_t _alert_low = 0; + bool _alert_latched = false; + uint8_t _shutdown_low = 0; + bool _shutting_down = false; +}; diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index a0bf9da..2eda4a9 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -236,6 +236,10 @@ struct NodePrefs { uint8_t wake_on_msg; // 0 = don't wake display on message, 1 = wake (default) uint16_t screen_off_secs; // 0 = default (Kconfig), else 5–300 uint16_t auto_shutdown_mv; // low-batt auto-shutdown threshold; 0 = off, else 2900–4200 + /* 1 = auto_shutdown_mv is a choice made after the default moved from + * 3300 to 3200 mV; a stored 3300 without it is the old default and + * becomes 3200 once (PrefsJson, the legacy loader). */ + uint8_t auto_shutdown_set; uint8_t v_contact_enabled; // v-contact (loopback admin chat via BLE/USB); 1 = on (default) uint16_t v_battery_alert_mv; // 0 = alert off; 0xFFFF = board default (auto_shutdown+200); else mV /* App-owned ContactInfo.flags byte for the v-contact. The v-contact never @@ -288,6 +292,18 @@ static inline uint8_t saneEnum(uint8_t v, uint8_t max) { return (v <= max) ? v : /* The GPS duty interval, in the one range every setter and the GPS manager * share: 0 = always on, else 10 s to 1 week (safely below the ms overflow). */ +/* The auto-shutdown default before 2026-09-25 (now 3200 mV): a stored + * 3300 without auto_shutdown_set is migrated (auto_shutdown_upgrade). */ +#define AUTO_SHUTDOWN_OLD_DEFAULT_MV 3300 +static inline void auto_shutdown_upgrade(NodePrefs *p) { +#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0 + if (!p->auto_shutdown_set && p->auto_shutdown_mv == AUTO_SHUTDOWN_OLD_DEFAULT_MV) { + p->auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; + } +#endif + p->auto_shutdown_set = 1; +} + #define GPS_INTERVAL_MIN_SEC 10u #define GPS_INTERVAL_MAX_SEC 604800u static inline uint32_t clampGpsInterval(uint32_t sec) { @@ -336,6 +352,7 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { p->buzzer_quiet = saneBool(p->buzzer_quiet, 0); p->gps_enabled = saneBool(p->gps_enabled, 0); p->gps_enabled_set = saneBool(p->gps_enabled_set, 0); + p->auto_shutdown_set = saneBool(p->auto_shutdown_set, 0); p->rx_duty_cycle = saneBool(p->rx_duty_cycle, 0); p->leds_disabled = saneBool(p->leds_disabled, 0); p->leds_radio_mode = saneEnum(p->leds_radio_mode, LEDS_RADIO_MAX); @@ -485,6 +502,7 @@ static inline void initNodePrefs(NodePrefs* prefs) { * through untouched — this default only applies to a fresh prefs struct. */ #ifdef CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS prefs->auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; + prefs->auto_shutdown_set = 1; #else prefs->auto_shutdown_mv = 0; #endif diff --git a/zephcore/helpers/PrefsJson.cpp b/zephcore/helpers/PrefsJson.cpp index ce6caa7..8d6eedc 100644 --- a/zephcore/helpers/PrefsJson.cpp +++ b/zephcore/helpers/PrefsJson.cpp @@ -135,6 +135,7 @@ protected: def("wake", _p->wake_on_msg); def("scr_off", _p->screen_off_secs); def("auto_off_mv", _p->auto_shutdown_mv); + def("aoff_set", _p->auto_shutdown_set); def("vc_en", _p->v_contact_enabled); def("vc_flags", _p->v_contact_flags); def("vbat_mv", _p->v_battery_alert_mv); @@ -316,9 +317,13 @@ bool companionPrefsFromJson(NodePrefs &p, Stream &in) NodePrefs t = p; CompanionJson j(&t); + /* Absent keys keep the caller's value: clear the marker first, so a file + * from before it existed is recognised (NodePrefs.h). */ + t.auto_shutdown_set = 0; if (!j.load(in)) { return false; } + auto_shutdown_upgrade(&t); saneRadio(t); sanitizeNodePrefs(&t); p = t; diff --git a/zephcore/helpers/sensors/LPPDataHelpers.h b/zephcore/helpers/sensors/LPPDataHelpers.h new file mode 100644 index 0000000..30116ca --- /dev/null +++ b/zephcore/helpers/sensors/LPPDataHelpers.h @@ -0,0 +1,292 @@ +#pragma once + +#include + +#define LPP_DIGITAL_INPUT 0 // 1 byte +#define LPP_DIGITAL_OUTPUT 1 // 1 byte +#define LPP_ANALOG_INPUT 2 // 2 bytes, 0.01 signed +#define LPP_ANALOG_OUTPUT 3 // 2 bytes, 0.01 signed +#define LPP_GENERIC_SENSOR 100 // 4 bytes, unsigned +#define LPP_LUMINOSITY 101 // 2 bytes, 1 lux unsigned +#define LPP_PRESENCE 102 // 1 byte, bool +#define LPP_TEMPERATURE 103 // 2 bytes, 0.1°C signed +#define LPP_RELATIVE_HUMIDITY 104 // 1 byte, 0.5% unsigned +#define LPP_ACCELEROMETER 113 // 2 bytes per axis, 0.001G +#define LPP_BAROMETRIC_PRESSURE 115 // 2 bytes 0.1hPa unsigned +#define LPP_VOLTAGE 116 // 2 bytes 0.01V unsigned +#define LPP_CURRENT 117 // 2 bytes 0.001A unsigned +#define LPP_FREQUENCY 118 // 4 bytes 1Hz unsigned +#define LPP_PERCENTAGE 120 // 1 byte 1-100% unsigned +#define LPP_ALTITUDE 121 // 2 byte 1m signed +#define LPP_CONCENTRATION 125 // 2 bytes, 1 ppm unsigned +#define LPP_POWER 128 // 2 byte, 1W, unsigned +#define LPP_DISTANCE 130 // 4 byte, 0.001m, unsigned +#define LPP_ENERGY 131 // 4 byte, 0.001kWh, unsigned +#define LPP_DIRECTION 132 // 2 bytes, 1deg, unsigned +#define LPP_UNIXTIME 133 // 4 bytes, unsigned +#define LPP_GYROMETER 134 // 2 bytes per axis, 0.01 °/s +#define LPP_COLOUR 135 // 1 byte per RGB Color +#define LPP_GPS 136 // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter +#define LPP_SWITCH 142 // 1 byte, 0/1 +#define LPP_POLYLINE 240 // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas + +// Multipliers +#define LPP_DIGITAL_INPUT_MULT 1 +#define LPP_DIGITAL_OUTPUT_MULT 1 +#define LPP_ANALOG_INPUT_MULT 100 +#define LPP_ANALOG_OUTPUT_MULT 100 +#define LPP_GENERIC_SENSOR_MULT 1 +#define LPP_LUMINOSITY_MULT 1 +#define LPP_PRESENCE_MULT 1 +#define LPP_TEMPERATURE_MULT 10 +#define LPP_RELATIVE_HUMIDITY_MULT 2 +#define LPP_ACCELEROMETER_MULT 1000 +#define LPP_BAROMETRIC_PRESSURE_MULT 10 +#define LPP_VOLTAGE_MULT 100 +#define LPP_CURRENT_MULT 1000 +#define LPP_FREQUENCY_MULT 1 +#define LPP_PERCENTAGE_MULT 1 +#define LPP_ALTITUDE_MULT 1 +#define LPP_POWER_MULT 1 +#define LPP_DISTANCE_MULT 1000 +#define LPP_ENERGY_MULT 1000 +#define LPP_DIRECTION_MULT 1 +#define LPP_UNIXTIME_MULT 1 +#define LPP_GYROMETER_MULT 100 +#define LPP_GPS_LAT_LON_MULT 10000 +#define LPP_GPS_ALT_MULT 100 +#define LPP_SWITCH_MULT 1 +#define LPP_CONCENTRATION_MULT 1 +#define LPP_COLOUR_MULT 1 + +#define LPP_ERROR_OK 0 +#define LPP_ERROR_OVERFLOW 1 +#define LPP_ERROR_UNKOWN_TYPE 2 + +class LPPData { +public: + static uint8_t getDataSize(uint8_t type) { + switch (type) { + case LPP_GPS: + return 9; + case LPP_POLYLINE: + return 8; // TODO: this is MINIMIUM + case LPP_GYROMETER: + case LPP_ACCELEROMETER: + return 6; + case LPP_GENERIC_SENSOR: + case LPP_FREQUENCY: + case LPP_DISTANCE: + case LPP_ENERGY: + case LPP_UNIXTIME: + return 4; + case LPP_COLOUR: + return 3; + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + case LPP_LUMINOSITY: + case LPP_TEMPERATURE: + case LPP_CONCENTRATION: + case LPP_BAROMETRIC_PRESSURE: + // ZEPHCORE: LPP_RELATIVE_HUMIDITY is 1 byte (see readRelativeHumidity() + // below and the CayenneLPP spec); listed here it misaligned every field + // after a humidity reading. It falls through to the 1-byte default. + case LPP_ALTITUDE: + case LPP_VOLTAGE: + case LPP_CURRENT: + case LPP_DIRECTION: + case LPP_POWER: + return 2; + } + return 1; + } + + static uint32_t getMultiplier(uint8_t type) { + switch (type) { + case LPP_CURRENT: + case LPP_DISTANCE: + case LPP_ENERGY: + return 1000; + case LPP_VOLTAGE: + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + return 100; + case LPP_TEMPERATURE: + case LPP_BAROMETRIC_PRESSURE: + case LPP_RELATIVE_HUMIDITY: + return 10; + } + return 1; + } + + static bool isSigned(uint8_t type) { + return type == LPP_ALTITUDE || type == LPP_TEMPERATURE || type == LPP_GYROMETER || + type == LPP_ANALOG_INPUT || type == LPP_ANALOG_OUTPUT || type == LPP_GPS || type == LPP_ACCELEROMETER; + } + + static float getFloat(const uint8_t * buffer, uint8_t size, uint32_t multiplier, bool is_signed) { + uint32_t value = 0; + for (uint8_t i = 0; i < size; i++) { + value = (value << 8) + buffer[i]; + } + + int sign = 1; + if (is_signed) { + uint32_t bit = 1ul << ((size * 8) - 1); + if ((value & bit) == bit) { + value = (bit << 1) - value; + sign = -1; + } + } + return sign * ((float) value / multiplier); + } + + static uint8_t putFloat(uint8_t * dest, float value, uint8_t size, uint32_t multiplier, bool is_signed) { + // check sign + bool sign = value < 0; + if (sign) value = -value; + + // get value to store + uint32_t v = value * multiplier; + + // format an uint32_t as if it was an int32_t + if (is_signed & sign) { + uint32_t mask = (1 << (size * 8)) - 1; + v = v & mask; + if (sign) v = mask - v + 1; + } + + // add bytes (MSB first) + for (uint8_t i=1; i<=size; i++) { + dest[size - i] = (v & 0xFF); + v >>= 8; + } + return size; + } + +}; + +class LPPReader { + const uint8_t* _buf; + uint8_t _len; + uint8_t _pos; + +public: + LPPReader(const uint8_t buf[], uint8_t len) : _buf(buf), _len(len), _pos(0) { } + + void reset() { + _pos = 0; + } + + bool readHeader(uint8_t& channel, uint8_t& type) { + if (_pos + 2 < _len) { + channel = _buf[_pos++]; + type = _buf[_pos++]; + + return channel != 0; // channel 0 is End-of-data + } + return false; // end-of-buffer + } + + bool readGPS(float& lat, float& lon, float& alt) { + lat = LPPData::getFloat(&_buf[_pos], 3, 10000, true); _pos += 3; + lon = LPPData::getFloat(&_buf[_pos], 3, 10000, true); _pos += 3; + alt = LPPData::getFloat(&_buf[_pos], 3, 100, true); _pos += 3; + return _pos <= _len; + } + bool readVoltage(float& voltage) { + voltage = LPPData::getFloat(&_buf[_pos], 2, 100, false); _pos += 2; + return _pos <= _len; + } + bool readCurrent(float& amps) { + amps = LPPData::getFloat(&_buf[_pos], 2, 1000, true); _pos += 2; + return _pos <= _len; + } + bool readPower(float& watts) { + watts = LPPData::getFloat(&_buf[_pos], 2, 1, false); _pos += 2; + return _pos <= _len; + } + bool readTemperature(float& degrees_c) { + degrees_c = LPPData::getFloat(&_buf[_pos], 2, 10, true); _pos += 2; + return _pos <= _len; + } + bool readPressure(float& pa) { + pa = LPPData::getFloat(&_buf[_pos], 2, 10, false); _pos += 2; + return _pos <= _len; + } + bool readRelativeHumidity(float& pct) { + pct = LPPData::getFloat(&_buf[_pos], 1, 2, false); _pos += 1; + return _pos <= _len; + } + bool readAltitude(float& m) { + m = LPPData::getFloat(&_buf[_pos], 2, 1, true); _pos += 2; + return _pos <= _len; + } + + void skipData(uint8_t type) { + _pos += LPPData::getDataSize(type); + } +}; + +class LPPWriter { + uint8_t* _buf; + uint8_t _max_len; + uint8_t _len; + + void write(uint16_t value) { + _buf[_len++] = (value >> 8) & 0xFF; // MSB + _buf[_len++] = value & 0xFF; // LSB + } + +public: + LPPWriter(uint8_t buf[], uint8_t max_len): _buf(buf), _max_len(max_len), _len(0) { } + + bool writeData(uint8_t channel, uint8_t type, float v) { + uint8_t sz = LPPData::getDataSize(type); + bool s = LPPData::isSigned(type); + uint32_t mul = LPPData::getMultiplier(type); + if (_len + 2 + sz <= _max_len) { + _buf[_len++] = channel; + _buf[_len++] = type; + _len += LPPData::putFloat(&_buf[_len], v, sz, mul, s); + return true; + } + return false; + } + + bool writeVoltage(uint8_t channel, float voltage) { + if (_len + 4 <= _max_len) { + _buf[_len++] = channel; + _buf[_len++] = LPP_VOLTAGE; + uint16_t value = voltage * 100; + write(value); + return true; + } + return false; + } + + bool writeGPS(uint8_t channel, float lat, float lon, float alt) { + if (_len + 11 <= _max_len) { + _buf[_len++] = channel; + _buf[_len++] = LPP_GPS; + + int32_t lati = lat * 10000; // we lose some precision :-( + int32_t loni = lon * 10000; + int32_t alti = alt * 100; + + _buf[_len++] = lati >> 16; + _buf[_len++] = lati >> 8; + _buf[_len++] = lati; + _buf[_len++] = loni >> 16; + _buf[_len++] = loni >> 8; + _buf[_len++] = loni; + _buf[_len++] = alti >> 16; + _buf[_len++] = alti >> 8; + _buf[_len++] = alti; + return true; + } + return false; + } + + uint8_t length() { return _len; } +}; diff --git a/zephcore/helpers/time_sync.h b/zephcore/helpers/time_sync.h index 394a488..706e1da 100644 --- a/zephcore/helpers/time_sync.h +++ b/zephcore/helpers/time_sync.h @@ -43,7 +43,10 @@ static inline enum time_sync_source time_sync_get_source(void) #endif /* CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK || _BUTTON */ -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) || \ + IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) +/* helpers/ui/time_sync.c: the last sync source as text (the joystick UI's + * System > Info > Time). */ const char *time_sync_display_label(void); #endif diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 71cb197..058dcb6 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -194,19 +194,6 @@ static const enum ui_page active_pages[] = { /* Current index into active_pages[] */ static int current_page_idx; -/* ========== Helper: Battery Percentage from mV ========== */ - -static uint8_t calc_battery_pct(uint16_t mv) -{ - if (mv >= 4200) { - return 100; - } - if (mv <= 3000) { - return 0; - } - return (uint8_t)((mv - 3000) * 100 / 1200); -} - /* ========== Helper: local wall clock ========== * * state.rtc_epoch is ALWAYS UTC. The timezone offset is applied here, at @@ -279,9 +266,6 @@ static void render_top_bar(void) if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; - if (pct == 0) { - pct = calc_battery_pct(state.battery_mv); - } snprintf(right + pos, sizeof(right) - pos, "%u%%", pct); } @@ -293,9 +277,6 @@ static void render_top_bar(void) if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; - if (pct == 0) { - pct = calc_battery_pct(state.battery_mv); - } batt_color = (pct <= 15) ? UI_COLOR_ERROR : (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; } @@ -612,8 +593,7 @@ static void render_tiny_title(void) if (state.battery_mv > 0) { char buf[8]; - uint8_t pct = state.battery_pct ? state.battery_pct - : calc_battery_pct(state.battery_mv); + uint8_t pct = state.battery_pct; snprintf(buf, sizeof(buf), "%u%%", pct); draw_centered(centered_row(1, rows), buf); @@ -1397,12 +1377,10 @@ static void render_sensors(void) /* Battery at bottom */ snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", - state.battery_pct > 0 ? state.battery_pct - : calc_battery_pct(state.battery_mv), + state.battery_pct, state.battery_mv); if (color) { - uint8_t pct = state.battery_pct > 0 ? state.battery_pct - : calc_battery_pct(state.battery_mv); + uint8_t pct = state.battery_pct; uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; @@ -1591,8 +1569,7 @@ static void render_status(void) draw_centered(centered_row(1, 3), buf); if (state.battery_mv > 0) { - uint8_t pct = state.battery_pct ? state.battery_pct - : calc_battery_pct(state.battery_mv); + uint8_t pct = state.battery_pct; snprintf(buf, sizeof(buf), "Batt %u%%", pct); draw_centered(centered_row(2, 3), buf); @@ -1653,9 +1630,6 @@ static void render_status(void) if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; - if (pct == 0) { - pct = calc_battery_pct(state.battery_mv); - } snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", pct, state.battery_mv); if (color) { uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index b924f22..638e44d 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -50,7 +50,6 @@ struct ui_state { /* Messages page */ uint16_t msg_count; bool ble_connected; - char device_name[25]; /* Recently heard */ struct { @@ -99,10 +98,6 @@ struct ui_state { bool leds_disabled; /* true = LEDs off */ /* Sensors page */ - int16_t temperature_c10; /* centi-degrees C */ - uint32_t pressure_pa; /* pascals */ - uint16_t humidity_rh10; /* 0.1% RH */ - uint16_t light_lux; /* lux */ /* Current page */ enum ui_page current_page; diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 067ef70..5047f30 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -203,6 +203,7 @@ static void splash_work_handler(struct k_work *work) static void schedule_render(void); static void schedule_render_auto(void); +static void set_ble_connected(bool connected); static void advert_defer_handler(struct k_work *work) { @@ -894,11 +895,11 @@ void ui_notify(enum ui_event event) break; case UI_EVENT_BLE_CONNECTED: - ui_set_ble_status(true, NULL); + set_ble_connected(true); break; case UI_EVENT_BLE_DISCONNECTED: - ui_set_ble_status(false, NULL); + set_ble_connected(false); break; default: @@ -954,15 +955,11 @@ void ui_set_msg_count(uint16_t count) #endif } -void ui_set_ble_status(bool connected, const char *name) +static void set_ble_connected(bool connected) { struct ui_state *s = get_state(); s->ble_connected = connected; - if (name) { - strncpy(s->device_name, name, sizeof(s->device_name) - 1); - s->device_name[sizeof(s->device_name) - 1] = '\0'; - } if (ui_initialized) { schedule_render(); @@ -1143,17 +1140,6 @@ void ui_set_node_name(const char *name) #endif } -void ui_set_sensor_data(int16_t temp_c10, uint32_t pressure_pa, - uint16_t humidity_rh10, uint16_t light_lux) -{ - struct ui_state *s = get_state(); - - s->temperature_c10 = temp_c10; - s->pressure_pa = pressure_pa; - s->humidity_rh10 = humidity_rh10; - s->light_lux = light_lux; -} - void ui_set_gps_available(bool available) { struct ui_state *s = get_state(); diff --git a/zephcore/helpers/ui-joystick/joystick_defs.h b/zephcore/helpers/ui-joystick/joystick_defs.h index 2790baa..1275f28 100644 --- a/zephcore/helpers/ui-joystick/joystick_defs.h +++ b/zephcore/helpers/ui-joystick/joystick_defs.h @@ -69,10 +69,6 @@ #define kBodyY 14 /* synonym for kContentY */ #define kLineH 9 /* compact line height */ -/* Battery range for percentage calculation */ -#define kBattMinMv 3000 -#define kBattMaxMv 4200 - /* Auto off timeout */ #define AUTO_OFF_MILLIS 30000UL /* 30 seconds */ diff --git a/zephcore/helpers/ui-joystick/joystick_screens.h b/zephcore/helpers/ui-joystick/joystick_screens.h index d9b38dd..a3a7e74 100644 --- a/zephcore/helpers/ui-joystick/joystick_screens.h +++ b/zephcore/helpers/ui-joystick/joystick_screens.h @@ -423,6 +423,12 @@ public: int render(JoystickDisplay &display) override; bool handleInput(char c) override; void onExit() override; + +private: + /* handleInput(), one per mode */ + bool handleMsgViewKey(char key); + bool handleSubmenuKey(char key); + bool handleEditPathKey(char key); }; /* ===== UnreadScreen ===== */ @@ -497,6 +503,11 @@ public: void onExit() override; private: + /* handleInput(), one per state */ + bool handlePasswordKey(char c); + bool handleSubmenuKey(char c); + bool handleMainKey(char c); + bool handleCmdInputKey(char c); struct k_timer _timeout_timer; static void timeoutTimerCb(struct k_timer *t); void onTimeout(); /* called from main thread when _timeout_timer fires */ diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index 36b64a0..762e57f 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -197,15 +197,6 @@ extern "C" void ui_notify_packet_sent(void) } } -extern "C" void ui_set_ble_status(bool connected, const char *name) -{ - (void)name; - if (s_task) { - s_task->setBLEConnected(connected); - s_task->notify(); - } -} - extern "C" void ui_set_radio_params( uint32_t freq_hz, uint8_t sf, @@ -249,10 +240,10 @@ extern "C" bool ui_joystick_try_match_ack(uint32_t ack, uint8_t out_pubkey[6]) return true; } -extern "C" void ui_set_battery(uint16_t mv, uint8_t /*pct*/) +extern "C" void ui_set_battery(uint16_t mv, uint8_t pct) { if (s_task) { - s_task->setCachedBattMilliVolts(mv); + s_task->setCachedBattery(mv, pct); } } @@ -273,7 +264,6 @@ 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) {} -extern "C" void ui_set_sensor_data(int16_t, uint32_t, uint16_t, uint16_t) {} extern "C" void ui_set_gps_available(bool) {} extern "C" void ui_set_gps_enabled(bool) {} extern "C" void ui_set_gps_state(uint8_t, uint32_t, uint32_t) {} diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 77fcd34..7a4c4be 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -770,10 +770,7 @@ void JoystickUITask::renderLockOverlay() * doesn't burn power). */ char batt_buf[16]; if (_cached_batt_mv > 0) { - int pct = ((int)_cached_batt_mv - kBattMinMv) * 100 / (kBattMaxMv - kBattMinMv); - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - snprintf(batt_buf, sizeof(batt_buf), "Batt: %d%%", pct); + snprintf(batt_buf, sizeof(batt_buf), "Batt: %u%%", _cached_batt_pct); } else { snprintf(batt_buf, sizeof(batt_buf), "Batt: --"); } @@ -1336,29 +1333,6 @@ bool JoystickUITask::sendComposedMessage(const char *text) return false; } -bool JoystickUITask::sendChannelMessage(const char *text) -{ - if (!text || !text[0] || _compose_channel_idx < 0 || !_mesh) return false; - ChannelDetails ch; - if (!_mesh->getChannel(_compose_channel_idx, ch)) return false; - uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); - return startPendingChannel((uint8_t)_compose_channel_idx, ch, ts, text); -} - -bool JoystickUITask::findContactByName(const char *name, ContactInfo &contact) -{ - if (!name || !_mesh) return false; - int n = _mesh->getNumContacts(); - for (int i = 0; i < n; i++) { - ContactInfo c; - if (_mesh->getContactByIdx(i, c) && strcmp(c.name, name) == 0) { - contact = c; - return true; - } - } - return false; -} - /* ===== RepeaterAdmin callbacks ===== */ void JoystickUITask::onRepeaterAdminLoginResult(const uint8_t *pub_key_prefix, bool success, uint8_t permissions, uint32_t server_time) @@ -1450,20 +1424,6 @@ int JoystickUITask::getRecentlyHeard(AdvertPath *dest, int max) const return cm ? cm->getRecentlyHeard(dest, max) : 0; } -bool JoystickUITask::getDiscoverSignal(const uint8_t *pubkey, int8_t &snr_out, - uint8_t *path_len_out) const -{ - for (int i = 0; i < DISCOVER_SIGNAL_TABLE_SIZE; i++) { - if (_discover_signals[i].valid && - memcmp(_discover_signals[i].pubkey, pubkey, PUB_KEY_SIZE) == 0) { - snr_out = _discover_signals[i].snr; - if (path_len_out) *path_len_out = _discover_signals[i].path_len; - return true; - } - } - return false; -} - void JoystickUITask::clearDiscoverSignals() { memset(_discover_signals, 0, sizeof(_discover_signals)); diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h index 3ce3f30..226ea5a 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -87,6 +87,8 @@ public: /* State accessors for screens */ uint16_t getCachedBattMilliVolts() const { return _cached_batt_mv; } + /* The board's own percentage (discharge curve or fuel gauge). */ + uint8_t getCachedBattPercent() const { return _cached_batt_pct; } int getBatteryDisplayMode() const { return _battery_display_mode; } void toggleBatteryDisplayMode() { _battery_display_mode = (_battery_display_mode + 1) % 2; } uint32_t getScreenOffMillis() const { return _screen_off_ms; } @@ -146,11 +148,7 @@ public: void setComposeChannel(int idx, const char *name); void setComposeContact(const ContactInfo &contact); bool sendComposedMessage(const char *text); - bool sendChannelMessage(const char *text); - bool findContactByName(const char *name, ContactInfo &contact); int getRecentlyHeard(AdvertPath *dest, int max) const; - bool getDiscoverSignal(const uint8_t *pubkey, int8_t &snr_out, - uint8_t *path_len_out = nullptr) const; void clearDiscoverSignals(); int getDiscoverCount() const; bool getDiscoverByIdx(int idx, uint8_t *pubkey_out, int8_t &snr_out, int8_t &snr_remote_out, @@ -165,7 +163,7 @@ public: JoystickDisplay &getDisplay() { return _display; } /* Battery cache (updated via ui_set_battery from ui_refresh_battery's render-path call) */ - void setCachedBattMilliVolts(uint16_t mv) { _cached_batt_mv = mv; } + void setCachedBattery(uint16_t mv, uint8_t pct) { _cached_batt_mv = mv; _cached_batt_pct = pct; } /* Radio stats (fed from main companion loop) */ void setNoiseFloor(int16_t nf) { _noise_floor = nf; } @@ -257,6 +255,7 @@ private: /* State */ uint16_t _cached_batt_mv; + uint8_t _cached_batt_pct = 0; int _battery_display_mode; uint8_t _brightness; bool _wake_on_msg; diff --git a/zephcore/helpers/ui-joystick/screens/contacts.cpp b/zephcore/helpers/ui-joystick/screens/contacts.cpp index 114fa47..dfbf3f6 100644 --- a/zephcore/helpers/ui-joystick/screens/contacts.cpp +++ b/zephcore/helpers/ui-joystick/screens/contacts.cpp @@ -445,218 +445,175 @@ int ContactsScreen::render(JoystickDisplay &display) return 500; } -bool ContactsScreen::handleInput(char key) +/* Message view input: handleInput() in CMODE_MSGVIEW. */ +bool ContactsScreen::handleMsgViewKey(char key) { - /* Message view input */ - if (_mode == CMODE_MSGVIEW) { - if (key == KEY_ENTER_LONG && _active_contact_valid && - _active_contact.type == ADV_TYPE_CHAT) { - _task->setComposeContact(_active_contact); - _task->gotoT9InputScreen(); + if (key == KEY_ENTER_LONG && _active_contact_valid && + _active_contact.type == ADV_TYPE_CHAT) { + _task->setComposeContact(_active_contact); + _task->gotoT9InputScreen(); + return true; + } + if (_chat_details) { + const char *msg = nullptr; + uint32_t ts = 0; + int max_scroll = 0; + if (_active_contact_valid && + _task->getContactMsgAt(_active_contact.name, _chat_selected, msg, ts)) { + JoystickDisplay &disp = _task->getDisplay(); + max_scroll = getMessageDetailMaxScrollSanitized(disp, msg, 20, 5, 4); + } + if (handleDetailScrollNavigation(key, _chat_detail_scroll, max_scroll)) return true; + if (key == KEY_ENTER || key == KEY_CANCEL || key == KEY_HOME) { + _chat_details = false; + _chat_detail_scroll = 0; return true; } - if (_chat_details) { - const char *msg = nullptr; - uint32_t ts = 0; - int max_scroll = 0; - if (_active_contact_valid && - _task->getContactMsgAt(_active_contact.name, _chat_selected, msg, ts)) { - JoystickDisplay &disp = _task->getDisplay(); - max_scroll = getMessageDetailMaxScrollSanitized(disp, msg, 20, 5, 4); - } - if (handleDetailScrollNavigation(key, _chat_detail_scroll, max_scroll)) return true; - if (key == KEY_ENTER || key == KEY_CANCEL || key == KEY_HOME) { - _chat_details = false; - _chat_detail_scroll = 0; - return true; - } - return false; - } - - int total = _task->getContactMsgCount(_active_contact.name); - if (total > 0 && handleCommonListNavigation(key, _chat_selected, total)) return true; - if (key == KEY_ENTER && total > 0) { _chat_details = true; _chat_detail_scroll = 0; return true; } - if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_SUBMENU; return true; } return false; } - /* Submenu input */ - if (_mode == CMODE_SUBMENU) { - /* Ping modal intercepts all input when active */ - if (_ping_modal_active) { - if (_ping_sent_at > 0 || _ping_rtt_ms == 0) { - /* In flight (TX done or still queued): CANCEL aborts */ - if (key == KEY_CANCEL || key == KEY_HOME) { - _task->cancelContactPing(); - _ping_sent_at = 0; - _ping_rtt_ms = 0; - _ping_modal_active = false; - _task->forceRefresh(); - } - } else { - /* Result/timeout: any key dismisses */ + int total = _task->getContactMsgCount(_active_contact.name); + if (total > 0 && handleCommonListNavigation(key, _chat_selected, total)) return true; + if (key == KEY_ENTER && total > 0) { _chat_details = true; _chat_detail_scroll = 0; return true; } + if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_SUBMENU; return true; } + return false; +} + +/* Submenu input: handleInput() in CMODE_SUBMENU. */ +bool ContactsScreen::handleSubmenuKey(char key) +{ + /* Ping modal intercepts all input when active */ + if (_ping_modal_active) { + if (_ping_sent_at > 0 || _ping_rtt_ms == 0) { + /* In flight (TX done or still queued): CANCEL aborts */ + if (key == KEY_CANCEL || key == KEY_HOME) { + _task->cancelContactPing(); + _ping_sent_at = 0; + _ping_rtt_ms = 0; _ping_modal_active = false; _task->forceRefresh(); } - return true; + } else { + /* Result/timeout: any key dismisses */ + _ping_modal_active = false; + _task->forceRefresh(); } - - const char *items[12]; - char text[12][48]; - int n = buildSubmenuItems(items, text, 12); - - if (handleCommonListNavigation(key, _submenu_selected, n)) return true; - - if (key == KEY_ENTER) { - if (_submenu_selected == _idx_send_message) { - _chat_selected = 0; - _chat_details = false; - _chat_detail_scroll = 0; - _header_marquee_ms = k_uptime_get_32(); - _mode = CMODE_MSGVIEW; - return true; - } - if (_submenu_selected == _idx_repeater_admin) { - _task->gotoRepeaterAdminScreen(_active_contact.id.pub_key, - _active_contact.name, true); - return true; - } - if (_submenu_selected == _idx_ping_zerohop) { - uint32_t est_timeout = 0; - _ping_rtt_ms = 0; - _ping_snr_remote = INT8_MIN; - if (_task->sendContactPingZeroHop(_active_contact, est_timeout)) { - _ping_sent_at = 0; /* set in onPacketSent() after RF TX */ - _ping_timeout_ms = 5000; - _ping_modal_active = true; - } else { - _task->showAlert("Ping failed", 700); - } - return true; - } - if (_submenu_selected == _idx_edit_path) { - ContactInfo *live = _task->getMesh()->lookupContactByPubKey( - _active_contact.id.pub_key, PUB_KEY_SIZE); - const ContactInfo *src = live ? live : &_active_contact; - memset(_editpath_hexbuf, 0, sizeof(_editpath_hexbuf)); - int hlen = 0; - /* out_path_len is packed (top 2 bits = hash_size-1, bottom 6 = hop - * count) — decode the real byte length instead of treating the - * encoded byte as a raw count (breaks for path_hash_mode > 0). */ - int nbytes = 0; - if (mesh::Packet::isValidPathLen(src->out_path_len)) { - uint8_t hash_size = (src->out_path_len >> 6) + 1; - nbytes = (src->out_path_len & 63) * hash_size; - } - for (int i = 0; i < nbytes && hlen < (int)sizeof(_editpath_hexbuf) - 2; i++) { - snprintf(_editpath_hexbuf + hlen, 3, "%02X", src->out_path[i]); - hlen += 2; - } - _editpath_cursor = hlen; - _editpath_t9_sel = 0; - _editpath_t9_last_key = -1; - _editpath_t9_letter_index = 0; - _editpath_t9_last_press = 0; - _editpath_kb_letters = true; - _editpath_confirm_exit = false; - _editpath_confirm_sel = 0; - _mode = CMODE_EDITPATH; - return true; - } - if (_submenu_selected == _idx_reset_path) { - ContactInfo *live = _task->getMesh()->lookupContactByPubKey( - _active_contact.id.pub_key, PUB_KEY_SIZE); - if (live) { - /* OUT_PATH_UNKNOWN (0xFF) marks "no saved path → flood". - * Plain 0 would mean "0-hop direct" and the contact would - * render as "direct" instead of forcing a flood-rediscover. */ - live->out_path_len = OUT_PATH_UNKNOWN; - memset(live->out_path, 0, sizeof(live->out_path)); - if (CompanionMesh *cm = static_cast(_task->getMesh())) { - cm->markContactsDirtyPublic(); - } - } - _task->showAlert("Path reset", 800); - return true; - } - if (_submenu_selected == _idx_favorite) { - ContactInfo c = _active_contact; - c.flags ^= 0x01; - ContactInfo old = _active_contact; - auto *mesh = _task->getMesh(); - if (mesh->removeContact(old)) { - mesh->addContact(c); - _active_contact = c; - } - return true; - } - if (_submenu_selected == _idx_delete) { - ContactInfo c = _active_contact; - _task->getMesh()->removeContact(c); - _active_contact_valid = false; - _mode = CMODE_LIST; - _task->showAlert("Deleted", 800); - return true; - } - } - if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_LIST; return true; } - return false; + return true; } - /* Edit path input */ - if (_mode == CMODE_EDITPATH) { - if (_editpath_confirm_exit) { - if (key == KEY_UP || key == KEY_DOWN) { - _editpath_confirm_sel = (_editpath_confirm_sel + 1) % 2; - return true; - } - if (key == KEY_ENTER) { - if (_editpath_confirm_sel == 0) { - /* save */ - ContactInfo *live = _task->getMesh()->lookupContactByPubKey( - _active_contact.id.pub_key, PUB_KEY_SIZE); - if (live) { - int hlen = (int)strlen(_editpath_hexbuf); - int nbytes = hlen / 2; - /* Encode with the mesh's *current* hash size, not always - * hash_size=1 — a path saved as 1-byte hops would silently - * mismatch every relay's isHashMatch() once path_hash_mode>0. */ - uint8_t hash_size = _task->getPathHashBytes(); - int max_bytes = hash_size * 63; - if (max_bytes > MAX_PATH_SIZE) max_bytes = MAX_PATH_SIZE; - if (nbytes > max_bytes) nbytes = max_bytes; - if (nbytes % hash_size != 0) { - _task->showAlert("Bad path length", 800); - } else { - for (int i = 0; i < nbytes; i++) { - char b[3] = { _editpath_hexbuf[i*2], _editpath_hexbuf[i*2+1], '\0' }; - live->out_path[i] = (uint8_t)strtol(b, nullptr, 16); - } - uint8_t hop_count = (uint8_t)(nbytes / hash_size); - live->out_path_len = (uint8_t)(((hash_size - 1) << 6) | (hop_count & 0x3F)); - _active_contact = *live; - _task->showAlert(nbytes == 0 ? "Path cleared" : "Path saved", 800); - } - } - } - _editpath_confirm_exit = false; - _mode = CMODE_SUBMENU; - return true; - } - if (key == KEY_CANCEL || key == KEY_HOME) { - _editpath_confirm_exit = false; - return true; - } - return false; + const char *items[12]; + char text[12][48]; + int n = buildSubmenuItems(items, text, 12); + + if (handleCommonListNavigation(key, _submenu_selected, n)) return true; + + if (key == KEY_ENTER) { + if (_submenu_selected == _idx_send_message) { + _chat_selected = 0; + _chat_details = false; + _chat_detail_scroll = 0; + _header_marquee_ms = k_uptime_get_32(); + _mode = CMODE_MSGVIEW; + return true; } - if (handleT9DirectionalInput(key, _editpath_t9_sel)) return true; - if (key == KEY_ENTER) { - if (_editpath_t9_sel == 3) { /* del */ - if (_editpath_cursor > 0) _editpath_hexbuf[--_editpath_cursor] = '\0'; - resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); - return true; + if (_submenu_selected == _idx_repeater_admin) { + _task->gotoRepeaterAdminScreen(_active_contact.id.pub_key, + _active_contact.name, true); + return true; + } + if (_submenu_selected == _idx_ping_zerohop) { + uint32_t est_timeout = 0; + _ping_rtt_ms = 0; + _ping_snr_remote = INT8_MIN; + if (_task->sendContactPingZeroHop(_active_contact, est_timeout)) { + _ping_sent_at = 0; /* set in onPacketSent() after RF TX */ + _ping_timeout_ms = 5000; + _ping_modal_active = true; + } else { + _task->showAlert("Ping failed", 700); } - if (_editpath_t9_sel == 11) { /* save */ + return true; + } + if (_submenu_selected == _idx_edit_path) { + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + const ContactInfo *src = live ? live : &_active_contact; + memset(_editpath_hexbuf, 0, sizeof(_editpath_hexbuf)); + int hlen = 0; + /* out_path_len is packed (top 2 bits = hash_size-1, bottom 6 = hop + * count) — decode the real byte length instead of treating the + * encoded byte as a raw count (breaks for path_hash_mode > 0). */ + int nbytes = 0; + if (mesh::Packet::isValidPathLen(src->out_path_len)) { + uint8_t hash_size = (src->out_path_len >> 6) + 1; + nbytes = (src->out_path_len & 63) * hash_size; + } + for (int i = 0; i < nbytes && hlen < (int)sizeof(_editpath_hexbuf) - 2; i++) { + snprintf(_editpath_hexbuf + hlen, 3, "%02X", src->out_path[i]); + hlen += 2; + } + _editpath_cursor = hlen; + _editpath_t9_sel = 0; + _editpath_t9_last_key = -1; + _editpath_t9_letter_index = 0; + _editpath_t9_last_press = 0; + _editpath_kb_letters = true; + _editpath_confirm_exit = false; + _editpath_confirm_sel = 0; + _mode = CMODE_EDITPATH; + return true; + } + if (_submenu_selected == _idx_reset_path) { + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + if (live) { + /* OUT_PATH_UNKNOWN (0xFF) marks "no saved path → flood". + * Plain 0 would mean "0-hop direct" and the contact would + * render as "direct" instead of forcing a flood-rediscover. */ + live->out_path_len = OUT_PATH_UNKNOWN; + memset(live->out_path, 0, sizeof(live->out_path)); + if (CompanionMesh *cm = static_cast(_task->getMesh())) { + cm->markContactsDirtyPublic(); + } + } + _task->showAlert("Path reset", 800); + return true; + } + if (_submenu_selected == _idx_favorite) { + ContactInfo c = _active_contact; + c.flags ^= 0x01; + ContactInfo old = _active_contact; + auto *mesh = _task->getMesh(); + if (mesh->removeContact(old)) { + mesh->addContact(c); + _active_contact = c; + } + return true; + } + if (_submenu_selected == _idx_delete) { + ContactInfo c = _active_contact; + _task->getMesh()->removeContact(c); + _active_contact_valid = false; + _mode = CMODE_LIST; + _task->showAlert("Deleted", 800); + return true; + } + } + if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_LIST; return true; } + return false; +} + +/* Edit path input: handleInput() in CMODE_EDITPATH. */ +bool ContactsScreen::handleEditPathKey(char key) +{ + if (_editpath_confirm_exit) { + if (key == KEY_UP || key == KEY_DOWN) { + _editpath_confirm_sel = (_editpath_confirm_sel + 1) % 2; + return true; + } + if (key == KEY_ENTER) { + if (_editpath_confirm_sel == 0) { + /* save */ ContactInfo *live = _task->getMesh()->lookupContactByPubKey( _active_contact.id.pub_key, PUB_KEY_SIZE); if (live) { @@ -682,35 +639,93 @@ bool ContactsScreen::handleInput(char key) _task->showAlert(nbytes == 0 ? "Path cleared" : "Path saved", 800); } } - _mode = CMODE_SUBMENU; - return true; - } - if (_editpath_t9_sel == 12) { /* mode toggle */ - _editpath_kb_letters = !_editpath_kb_letters; - resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); - return true; - } - if (_editpath_t9_sel == 7 || _editpath_t9_sel == 15) return true; /* spc/unused */ - const char *letters = getT9KeyLetters(_editpath_kb_letters)[_editpath_t9_sel]; - if (letters && letters[0]) { - appendOrCycleT9Char(_editpath_hexbuf, _editpath_cursor, - sizeof(_editpath_hexbuf), letters, - _editpath_t9_sel, _editpath_t9_last_key, - _editpath_t9_letter_index, _editpath_t9_last_press); } + _editpath_confirm_exit = false; + _mode = CMODE_SUBMENU; return true; } if (key == KEY_CANCEL || key == KEY_HOME) { - if (_editpath_cursor > 0) { - _editpath_confirm_exit = true; - _editpath_confirm_sel = 0; - } else { - _mode = CMODE_SUBMENU; - } + _editpath_confirm_exit = false; return true; } return false; } + if (handleT9DirectionalInput(key, _editpath_t9_sel)) return true; + if (key == KEY_ENTER) { + if (_editpath_t9_sel == 3) { /* del */ + if (_editpath_cursor > 0) _editpath_hexbuf[--_editpath_cursor] = '\0'; + resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); + return true; + } + if (_editpath_t9_sel == 11) { /* save */ + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + if (live) { + int hlen = (int)strlen(_editpath_hexbuf); + int nbytes = hlen / 2; + /* Encode with the mesh's *current* hash size, not always + * hash_size=1 — a path saved as 1-byte hops would silently + * mismatch every relay's isHashMatch() once path_hash_mode>0. */ + uint8_t hash_size = _task->getPathHashBytes(); + int max_bytes = hash_size * 63; + if (max_bytes > MAX_PATH_SIZE) max_bytes = MAX_PATH_SIZE; + if (nbytes > max_bytes) nbytes = max_bytes; + if (nbytes % hash_size != 0) { + _task->showAlert("Bad path length", 800); + } else { + for (int i = 0; i < nbytes; i++) { + char b[3] = { _editpath_hexbuf[i*2], _editpath_hexbuf[i*2+1], '\0' }; + live->out_path[i] = (uint8_t)strtol(b, nullptr, 16); + } + uint8_t hop_count = (uint8_t)(nbytes / hash_size); + live->out_path_len = (uint8_t)(((hash_size - 1) << 6) | (hop_count & 0x3F)); + _active_contact = *live; + _task->showAlert(nbytes == 0 ? "Path cleared" : "Path saved", 800); + } + } + _mode = CMODE_SUBMENU; + return true; + } + if (_editpath_t9_sel == 12) { /* mode toggle */ + _editpath_kb_letters = !_editpath_kb_letters; + resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); + return true; + } + if (_editpath_t9_sel == 7 || _editpath_t9_sel == 15) return true; /* spc/unused */ + const char *letters = getT9KeyLetters(_editpath_kb_letters)[_editpath_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_editpath_hexbuf, _editpath_cursor, + sizeof(_editpath_hexbuf), letters, + _editpath_t9_sel, _editpath_t9_last_key, + _editpath_t9_letter_index, _editpath_t9_last_press); + } + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { + if (_editpath_cursor > 0) { + _editpath_confirm_exit = true; + _editpath_confirm_sel = 0; + } else { + _mode = CMODE_SUBMENU; + } + return true; + } + return false; +} + +bool ContactsScreen::handleInput(char key) +{ + if (_mode == CMODE_MSGVIEW) { + return handleMsgViewKey(key); + } + + if (_mode == CMODE_SUBMENU) { + return handleSubmenuKey(key); + } + + if (_mode == CMODE_EDITPATH) { + return handleEditPathKey(key); + } /* Contact list input */ int contactCount = getFilteredContactCount(); @@ -764,918 +779,3 @@ void ContactsScreen::onPacketSent() _task->forceRefresh(); } } - -/* ===== RepeaterAdminScreen ===== */ - -#define ADMIN_TIMEOUT_MS 12000 - -static const int kAdminContentY = 23; -static const int kAdminVisible = 4; - -/* ===== Constructor ===== */ - -RepeaterAdminScreen::RepeaterAdminScreen(JoystickUITask *task, mesh::RTCClock *rtc) - : _task(task), _rtc(rtc), - _state(STATE_PASSWORD_ENTRY), _from_contacts(false), - _permissions(0), _server_time(0), - _pwd_len(0), - _pwd_t9_sel(0), _pwd_t9_last_key(-1), _pwd_t9_letter_index(0), _pwd_t9_last_press(0), - _pwd_kb_letters(true), - _submenu_sel(0), - _pending(PENDING_NONE), - _last_sent_at(0), _cmd_timeout_ms(ADMIN_TIMEOUT_MS), _awaiting_tx(false), - _hist_head(0), _hist_count(0), _hist_scroll(0), _resp_line_scroll(0), _resp_max_line_scroll(0), - _cmd_len(0), - _cmd_t9_sel(0), _cmd_t9_last_key(-1), _cmd_t9_letter_index(0), _cmd_t9_last_press(0), - _cmd_kb_letters(true) -{ - memset(_contact_pubkey, 0, sizeof(_contact_pubkey)); - memset(_repeater_name, 0, sizeof(_repeater_name)); - memset(_password, 0, sizeof(_password)); - memset(_hist, 0, sizeof(_hist)); - memset(_cmd_buf, 0, sizeof(_cmd_buf)); - k_timer_init(&_timeout_timer, timeoutTimerCb, NULL); - k_timer_user_data_set(&_timeout_timer, this); -} - -void RepeaterAdminScreen::timeoutTimerCb(struct k_timer *t) -{ - /* ISR — wake main loop; render() detects elapsed and dispatches to onTimeout(). */ - auto *self = static_cast(k_timer_user_data_get(t)); - if (self && self->_task) self->_task->notify(); -} - -void RepeaterAdminScreen::onExit() -{ - k_timer_stop(&_timeout_timer); -} - -void RepeaterAdminScreen::onTimeout() -{ - if (_state == STATE_LOGGING_IN) { - _awaiting_tx = false; - _state = STATE_PASSWORD_ENTRY; - _task->showAlert("Login timeout", 1500); - } else if ((_state == STATE_SUBMENU || _state == STATE_MAIN || _state == STATE_CMD_INPUT) && - _pending != PENDING_NONE) { - _awaiting_tx = false; - if (_hist_count > 0) { - CmdEntry &newest = histAt(_hist_count - 1); - if (!newest.has_resp) { - snprintf(newest.resp, sizeof(newest.resp), "(no response)"); - newest.has_resp = true; - } - } - _pending = PENDING_NONE; - _task->clearAdminReqTag(); - } - _last_sent_at = 0; /* prevent re-fire from the render() elapsed-check */ -} - -/* ===== openForContact ===== */ - -void RepeaterAdminScreen::openForContact(const uint8_t *pub_key, const char *name, - bool from_contacts) -{ - if (pub_key) memcpy(_contact_pubkey, pub_key, PUB_KEY_SIZE); - else memset(_contact_pubkey, 0, PUB_KEY_SIZE); - _from_contacts = from_contacts; - strncpy(_repeater_name, name ? name : "?", sizeof(_repeater_name) - 1); - _repeater_name[sizeof(_repeater_name) - 1] = '\0'; - _admin_header_marquee_ms = k_uptime_get_32(); - - _state = STATE_PASSWORD_ENTRY; - _pwd_len = 0; - memset(_password, 0, sizeof(_password)); - _pwd_t9_sel = 0; _pwd_t9_last_key = -1; - _pwd_t9_letter_index = 0; _pwd_t9_last_press = 0; - _pwd_kb_letters = true; - - _submenu_sel = 0; - _pending = PENDING_NONE; - _cmd_timeout_ms = ADMIN_TIMEOUT_MS; - _awaiting_tx = false; - _hist_head = 0; _hist_count = 0; _hist_scroll = 0; _resp_line_scroll = 0; _resp_max_line_scroll = 0; - memset(_hist, 0, sizeof(_hist)); -} - -/* ===== sendBinaryReq — used for guest-accessible requests ===== */ - -static bool sendBinaryReqHelper(JoystickUITask *task, const uint8_t *contact_pubkey, - const uint8_t *req_data, uint8_t req_len, - uint32_t &cmd_timeout_ms, bool &awaiting_tx, - uint32_t &last_sent_at) -{ - BaseChatMesh *mesh = task->getMesh(); - if (!mesh) return false; - ContactInfo *contact = mesh->lookupContactByPubKey(contact_pubkey, PUB_KEY_SIZE); - if (!contact) { - task->showAlert("Contact lost", 1500); - return false; - } - uint32_t tag, est_timeout = 0; - int result = mesh->sendRequest(*contact, req_data, req_len, tag, est_timeout); - if (result == MSG_SEND_FAILED) { - task->showAlert("Send failed", 1500); - return false; - } - task->registerAdminReqTag(tag); - ui_signal_tx(); - last_sent_at = k_uptime_get_32(); - awaiting_tx = true; - cmd_timeout_ms = ADMIN_TIMEOUT_MS; - if (est_timeout > 0) { - uint32_t rt = est_timeout * 2 + 3000; - if (rt > cmd_timeout_ms) cmd_timeout_ms = rt; - } - return true; -} - -/* ===== onReqResponse — parse binary REQ responses for guest shortcuts ===== */ - -void RepeaterAdminScreen::onReqResponse(const uint8_t *pub_key_prefix, - const uint8_t *data, uint8_t data_len) -{ - if (memcmp(_contact_pubkey, pub_key_prefix, 4) != 0) return; - if (_pending != PENDING_BINARY_STATUS && _pending != PENDING_BINARY_NEIGHBOURS && - _pending != PENDING_BINARY_TELEMETRY && _pending != PENDING_BINARY_OWNER_INFO) return; - - char text[ADMIN_RESP_MAX]; - text[0] = '\0'; - - if (_pending == PENDING_BINARY_STATUS && data_len >= 4) { - /* RepeaterStats struct layout (byte offsets from start of payload): - * 0: batt_milli_volts (u16) 2: curr_tx_queue_len (u16) - * 4: noise_floor (i16) 6: last_rssi (i16) - * 8: n_packets_recv (u32) 12: n_packets_sent (u32) - * 16: total_air_time_secs (u32) 20: total_up_time_secs (u32) - * 24: n_sent_flood (u32) 28: n_sent_direct (u32) - * 32: n_recv_flood (u32) 36: n_recv_direct (u32) - * 40: err_events (u16) 42: last_snr (i16, raw×4) - * 44: n_direct_dups (u16) 46: n_flood_dups (u16) - * 48: total_rx_air_time_secs (u32) - * 52: n_recv_errors (u32) */ - uint16_t batt = 0, txq = 0, errf = 0, dup_d = 0, dup_f = 0; - int16_t noise = 0, rssi = 0, lsnr = 0; - uint32_t air_tx = 0, rx_air = 0, uptime = 0; - uint32_t ptx = 0, prx = 0; - uint32_t sent = 0, recvd = 0, sent_d = 0, recvd_d = 0, errors = 0; - if (data_len >= 2) memcpy(&batt, &data[0], 2); - if (data_len >= 4) memcpy(&txq, &data[2], 2); - if (data_len >= 6) memcpy(&noise, &data[4], 2); - if (data_len >= 8) memcpy(&rssi, &data[6], 2); - if (data_len >= 12) memcpy(&prx, &data[8], 4); - if (data_len >= 16) memcpy(&ptx, &data[12], 4); - if (data_len >= 20) memcpy(&air_tx, &data[16], 4); - if (data_len >= 24) memcpy(&uptime, &data[20], 4); - if (data_len >= 28) memcpy(&sent, &data[24], 4); - if (data_len >= 32) memcpy(&sent_d, &data[28], 4); - if (data_len >= 36) memcpy(&recvd, &data[32], 4); - if (data_len >= 40) memcpy(&recvd_d, &data[36], 4); - if (data_len >= 42) memcpy(&errf, &data[40], 2); - if (data_len >= 44) memcpy(&lsnr, &data[42], 2); - if (data_len >= 46) memcpy(&dup_d, &data[44], 2); - if (data_len >= 48) memcpy(&dup_f, &data[46], 2); - if (data_len >= 52) memcpy(&rx_air, &data[48], 4); - if (data_len >= 56) memcpy(&errors, &data[52], 4); - char up_buf[12], air_buf[12], rair_buf[12]; - formatAge(uptime, up_buf, sizeof(up_buf)); - formatAge(air_tx, air_buf, sizeof(air_buf)); - formatAge(rx_air, rair_buf, sizeof(rair_buf)); - snprintf(text, sizeof(text), - "up:%s\nbatt:%umV\ntxq:%u\nnf:%ddB\nrssi:%ddBm\nsnr:%ddB\n" - "ptx:%u\nprx:%u\nftx:%u\nfrx:%u\ndtx:%u\ndrx:%u\n" - "err:%u\nef:%04X\ndup f:%u d:%u\nair:%s\nrair:%s", - up_buf, - (unsigned)batt, (unsigned)txq, - (int)noise, (int)rssi, - (int)(lsnr / 4), - (unsigned)ptx, (unsigned)prx, - (unsigned)sent, (unsigned)recvd, - (unsigned)sent_d, (unsigned)recvd_d, - (unsigned)errors, (unsigned)errf, - (unsigned)dup_f, (unsigned)dup_d, - air_buf, rair_buf); - } else if (_pending == PENDING_BINARY_NEIGHBOURS && data_len >= 4) { - /* Neighbours response layout (after tag): - * 0..1: total_count (i16) - * 2..3: returned_count (i16) - * 4+: per entry = 4 bytes pubkey prefix + 4 bytes secs_ago + 1 byte snr */ - int16_t total = 0, returned = 0; - memcpy(&total, &data[0], 2); - memcpy(&returned, &data[2], 2); - if (returned == 0) { - snprintf(text, sizeof(text), "-none-"); - } else { - char *dp = text; - char *end = text + sizeof(text) - 1; - int offset = 4; - for (int i = 0; i < returned && offset + 9 <= data_len && dp < end - 20; i++) { - if (i > 0) *dp++ = '\n'; - char hex[12]; - snprintf(hex, sizeof(hex), "!%02X%02X%02X%02X", - data[offset], data[offset+1], data[offset+2], data[offset+3]); - uint32_t secs_ago = 0; - int8_t snr = 0; - memcpy(&secs_ago, &data[offset + 4], 4); - memcpy(&snr, &data[offset + 8], 1); - ContactInfo *nc = _task->getMesh()->lookupContactByPubKey(&data[offset], 4); - char name_buf[32]; - const char *label; - if (nc && nc->name[0]) { - _task->getDisplay().translateUTF8ToBlocks(name_buf, nc->name, sizeof(name_buf)); - label = name_buf; - } else { - label = hex; - } - char age_buf[8]; - formatAge(secs_ago, age_buf, sizeof(age_buf)); - dp += snprintf(dp, end - dp, "%s %s %+ddB", label, age_buf, (int)(snr / 4)); - offset += 9; - } - *dp = '\0'; - } - } else if (_pending == PENDING_BINARY_TELEMETRY && data_len >= 3) { - /* CayenneLPP tuples: [channel:1][type:1][data:N big endian] */ - char *dp = text; - char *end = text + sizeof(text) - 1; - int offset = 0; - bool parse_ok = true; - while (parse_ok && offset + 2 <= data_len && dp < end - 22) { - uint8_t type = data[offset + 1]; - offset += 2; - char *entry_start = dp; - if (dp > text) *dp++ = '\n'; - bool entry_ok = false; - switch (type) { - case 116: { /* VOLTAGE: 2B unsigned BE ÷100 = V */ - if (offset + 2 > data_len) break; - uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; - offset += 2; - dp += snprintf(dp, end - dp, "batt:%u.%02uV", raw / 100, raw % 100); - entry_ok = true; - break; - } - case 103: { /* TEMPERATURE: 2B signed BE ÷10 = °C */ - if (offset + 2 > data_len) break; - int16_t raw = ((int16_t)data[offset] << 8) | data[offset + 1]; - offset += 2; - int abst = (raw < 0) ? -(int)raw : (int)raw; - dp += snprintf(dp, end - dp, "temp:%s%d.%dC", (raw < 0 ? "-" : ""), abst / 10, abst % 10); - entry_ok = true; - break; - } - case 104: { /* HUMIDITY: 1B ÷2 = % */ - if (offset + 1 > data_len) break; - dp += snprintf(dp, end - dp, "hum:%u%%", (unsigned)(data[offset] / 2)); - offset += 1; - entry_ok = true; - break; - } - case 115: { /* PRESSURE: 2B unsigned BE ÷10 = hPa */ - if (offset + 2 > data_len) break; - uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; - offset += 2; - dp += snprintf(dp, end - dp, "pres:%u.%uhPa", raw / 10, raw % 10); - entry_ok = true; - break; - } - case 117: { /* CURRENT: 2B unsigned BE (value in mA) */ - if (offset + 2 > data_len) break; - uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; - offset += 2; - dp += snprintf(dp, end - dp, "cur:%umA", (unsigned)raw); - entry_ok = true; - break; - } - case 128: { /* POWER: 2B unsigned BE = W */ - if (offset + 2 > data_len) break; - uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; - offset += 2; - dp += snprintf(dp, end - dp, "pwr:%uW", (unsigned)raw); - entry_ok = true; - break; - } - default: - parse_ok = false; - break; - } - if (!entry_ok) { - dp = entry_start; /* rollback partial newline */ - parse_ok = false; - } - } - *dp = '\0'; - if (dp == text) snprintf(text, sizeof(text), "no telemetry"); - } else if (_pending == PENDING_BINARY_OWNER_INFO && data_len > 0) { - /* Plain text from repeater: firmware_version\nnode_name\nowner_info */ - char raw[ADMIN_RESP_MAX]; - int len = data_len < (int)(sizeof(raw) - 1) ? data_len : (int)(sizeof(raw) - 1); - memcpy(raw, data, len); - raw[len] = '\0'; - const char *fields[3] = { raw, "", "" }; - int fi = 0; - for (char *p = raw; *p && fi < 2; p++) { - if (*p == '\n') { *p = '\0'; fields[++fi] = p + 1; } - } - /* labels add ~19 chars, so format into a larger buffer then truncate */ - char fmt[ADMIN_RESP_MAX + 24]; - snprintf(fmt, sizeof(fmt), "FW: %s\nNode: %s\nOwner: %s", fields[0], fields[1], fields[2]); - strncpy(text, fmt, sizeof(text) - 1); - text[sizeof(text) - 1] = '\0'; - } - - if (text[0] && _hist_count > 0) { - CmdEntry &newest = histAt(_hist_count - 1); - if (!newest.has_resp) { - int len = (int)strlen(text); - if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; - memcpy(newest.resp, text, len); - newest.resp[len] = '\0'; - newest.has_resp = true; - } - } - _pending = PENDING_NONE; - _task->clearAdminReqTag(); - k_timer_stop(&_timeout_timer); -} - -/* ===== sendCLI ===== */ - -bool RepeaterAdminScreen::sendCLI(const char *cmd) -{ - BaseChatMesh *mesh = _task->getMesh(); - if (!mesh) return false; - ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); - if (!contact) { - _task->showAlert("Contact lost", 1500); - return false; - } - uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); - uint32_t est_timeout = 0; - /* TXT_TYPE_CLI_DATA, not TXT_TYPE_CLI_COMMAND: this talks to repeaters of - * any vintage, and type 3 is only understood from v1.18 on. */ - int result = mesh->sendCommandData(*contact, ts, 0, TXT_TYPE_CLI_DATA, cmd, est_timeout); - if (result == MSG_SEND_FAILED) { - _task->showAlert("Send failed", 1500); - return false; - } - ui_signal_tx(); - _last_sent_at = k_uptime_get_32(); - _awaiting_tx = true; - /* est_timeout is one way, CLI response takes a second packet */ - _cmd_timeout_ms = ADMIN_TIMEOUT_MS; - if (est_timeout > 0) { - uint32_t rt = est_timeout * 2 + 3000; - if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; - } - k_timer_stop(&_timeout_timer); - k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); - return true; -} - -/* ===== Callbacks ===== */ - -void RepeaterAdminScreen::onLoginResult(bool success, uint8_t permissions, uint32_t server_time) -{ - if (_state != STATE_LOGGING_IN) return; - k_timer_stop(&_timeout_timer); - if (success) { - _permissions = permissions; - _server_time = server_time; - _state = STATE_SUBMENU; - _submenu_sel = 0; - _pending = PENDING_NONE; - _hist_scroll = 0; _resp_line_scroll = 0; - _task->showAlert("Login OK", 600); - } else { - _state = STATE_PASSWORD_ENTRY; - _task->showAlert("Login failed", 1500); - } -} - -void RepeaterAdminScreen::onCliResponse(const char *text) -{ - if (!text || _pending != PENDING_CMD) return; - if (_state != STATE_MAIN && _state != STATE_CMD_INPUT && _state != STATE_SUBMENU) return; - k_timer_stop(&_timeout_timer); - if (_hist_count > 0) { - CmdEntry &newest = histAt(_hist_count - 1); - if (!newest.has_resp) { - int len = (int)strlen(text); - if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; - memcpy(newest.resp, text, len); - newest.resp[len] = '\0'; - newest.has_resp = true; - } - } - _pending = PENDING_NONE; -} - -void RepeaterAdminScreen::onPacketSent() -{ - /* RF TX completed — reset timeout clock so LBT/queue delay is excluded. - * Restart the response-timeout timer with the full window from now. */ - if (_awaiting_tx) { - _last_sent_at = k_uptime_get_32(); - _awaiting_tx = false; - k_timer_stop(&_timeout_timer); - k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); - } -} - -/* ===== goBack ===== */ - -void RepeaterAdminScreen::goBack() -{ - if (_from_contacts) _task->gotoContactsScreen(); - else _task->gotoRepeatersScreen(); -} - -/* ===== Render helpers ===== */ - -static void renderAdminHeader(JoystickDisplay &display, const char *name, bool is_admin, - uint32_t marquee_ms) -{ - int name_w = display.width(); - display.setTextSize(1); - display.setColor(JoystickDisplay::GREEN); - if (name_w > 0 && display.getTextWidth(name) > name_w) { - int text_len = (int)strlen(name); - if (text_len > 0) { - static const uint32_t kPauseMs = 1500; - uint32_t elapsed = k_uptime_get_32() - marquee_ms; - uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; - int offset = (int)((scroll_elapsed / 450U) % (uint32_t)text_len); - display.drawTextEllipsized(0, 0, name_w, name + offset); - } - } else { - display.drawTextEllipsized(0, 0, name_w, name); - } - display.drawRect(0, kHeaderSepY, display.width(), 1); -} - -/* ===== render ===== */ - -int RepeaterAdminScreen::render(JoystickDisplay &display) -{ - /* Timeout check: timer fired (or any wakeup arrived past deadline). */ - if (_last_sent_at > 0 && - (k_uptime_get_32() - _last_sent_at) > _cmd_timeout_ms) { - onTimeout(); - } - - switch (_state) { - - case STATE_PASSWORD_ENTRY: { - /* Header: "Password: " on one line */ - display.setTextSize(1); - display.setColor(JoystickDisplay::GREEN); - const char *prefix = "Password: "; - int prefix_w = display.getTextWidth(prefix); - display.drawTextLeftAlign(0, 0, prefix); - int name_avail = display.width() - prefix_w; - if (name_avail > 0) { - int name_len = (int)strlen(_repeater_name); - if (name_len > 0 && display.getTextWidth(_repeater_name) > name_avail) { - static const uint32_t kPauseMs = 1500; - uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; - uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; - int offset = (int)((scroll_elapsed / 450U) % (uint32_t)name_len); - display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name + offset); - } else { - display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name); - } - } - display.drawRect(0, kHeaderSepY, display.width(), 1); - /* Password input */ - char pwd_display[ADMIN_PASSWORD_MAX + 2]; - int n = (_pwd_len < ADMIN_PASSWORD_MAX) ? _pwd_len : ADMIN_PASSWORD_MAX; - memcpy(pwd_display, _password, n); - pwd_display[n] = '|'; pwd_display[n + 1] = '\0'; - display.setColor(JoystickDisplay::YELLOW); - display.drawTextLeftAlign(0, kContentY, pwd_display); - renderT9Keypad(display, getT9KeyLabels(_pwd_kb_letters, true), _pwd_t9_sel, kContentY + kMenuLineH); - return 300; - } - - case STATE_LOGGING_IN: { - renderScreenHeader(display, _repeater_name, 0, 0); - display.setTextSize(1); - display.setColor(JoystickDisplay::GREEN); - display.drawTextCentered(display.width() / 2, 28, "Logging in..."); - uint32_t d = (k_uptime_get_32() / 333) % 3; - const char *dots[] = { ".", "..", "..." }; - display.drawTextCentered(display.width() / 2, 38, dots[d]); - return 333; - } - - case STATE_SUBMENU: { - renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); - display.setTextSize(1); - display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); - display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, - _permissions != 0 ? "Admin" : "Guest"); - static const char * const kItems[] = { "Neighbours", "Stats", "Telemetry", "Info", "Commands", "Time Sync" }; - int count = (_permissions != 0) ? 6 : 4; - int max_vis = (display.height() - kAdminContentY) / kMenuLineH; - if (max_vis < 1) max_vis = 1; - int start = computeListStart(_submenu_sel, count, max_vis); - int y = kAdminContentY; - for (int i = start; i < count && i < start + max_vis; i++) { - renderMenuListText(display, y, i == _submenu_sel, kItems[i]); - y += kMenuLineH; - } - return 500; - } - - case STATE_MAIN: { - renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); - display.setTextSize(1); - display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); - display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, - _permissions != 0 ? "Admin" : "Guest"); - if (_hist_count == 0) { - display.setColor(JoystickDisplay::LIGHT); - display.drawTextLeftAlign(0, kAdminContentY, "(no history)"); - display.drawTextLeftAlign(0, kAdminContentY + kLineH, "Long ENTER: send cmd"); - } else { - int logical = _hist_count - 1 - _hist_scroll; - if (logical < 0) logical = 0; - const CmdEntry &e = histAt(logical); - int y = kAdminContentY; - - /* Command line */ - char cmd_line[ADMIN_CMD_MAX + 12]; - if (_hist_count > 1) - snprintf(cmd_line, sizeof(cmd_line), "[%d/%d] > %s", - logical + 1, _hist_count, e.cmd); - else - snprintf(cmd_line, sizeof(cmd_line), "> %s", e.cmd); - display.setColor(JoystickDisplay::YELLOW); - display.drawTextEllipsized(0, y, display.width(), cmd_line); - y += kLineH; - - /* Response — show all lines until display bottom */ - if (!e.has_resp && _pending != PENDING_NONE && logical == _hist_count - 1) { - display.setColor(JoystickDisplay::LIGHT); - display.drawTextLeftAlign(2, y, "(waiting...)"); - } else if (e.has_resp && e.resp[0]) { - /* Count lines and compute vertical scroll limits */ - int resp_lines = 1; - for (const char *p = e.resp; *p; p++) if (*p == '\n') resp_lines++; - int vis_lines = (display.height() - y) / kLineH; - _resp_max_line_scroll = (resp_lines > vis_lines) ? resp_lines - vis_lines : 0; - if (_resp_line_scroll > _resp_max_line_scroll) _resp_line_scroll = _resp_max_line_scroll; - - /* Skip lines above the scroll offset */ - const char *rp = e.resp; - for (int skip = 0; skip < _resp_line_scroll && rp; skip++) { - const char *nl = strchr(rp, '\n'); - rp = nl ? nl + 1 : nullptr; - } - - bool is_scrollable = (strcmp(e.cmd, "neighbors") == 0 || strcmp(e.cmd, "info") == 0); - uint32_t scroll_elapsed = 0; - if (is_scrollable) { - static const uint32_t kNbPauseMs = 1500; - uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; - scroll_elapsed = (elapsed > kNbPauseMs) ? (elapsed - kNbPauseMs) : 0; - } - while (rp && *rp && y + kLineH <= display.height()) { - const char *nl = strchr(rp, '\n'); - int rlen = nl ? (int)(nl - rp) : (int)strlen(rp); - display.setColor(JoystickDisplay::GREEN); - if (is_scrollable && rlen > 0) { - char line_buf[ADMIN_RESP_MAX]; - int copy = rlen < (int)(sizeof(line_buf) - 1) ? rlen : (int)(sizeof(line_buf) - 1); - memcpy(line_buf, rp, copy); - line_buf[copy] = '\0'; - int line_w = display.width(); - if (display.getTextWidth(line_buf) > line_w) { - int offset = (int)((scroll_elapsed / 450U) % (uint32_t)copy); - display.drawTextEllipsized(0, y, line_w, line_buf + offset); - } else { - char prefixed[ADMIN_RESP_MAX + 2]; - prefixed[0] = ' '; prefixed[1] = ' '; - memcpy(prefixed + 2, line_buf, copy); - prefixed[copy + 2] = '\0'; - display.drawTextEllipsized(0, y, line_w, prefixed); - } - } else { - char resp_line[42]; - int copy = rlen < 39 ? rlen : 39; - resp_line[0] = ' '; resp_line[1] = ' '; - memcpy(resp_line + 2, rp, copy); - resp_line[copy + 2] = '\0'; - display.drawTextEllipsized(0, y, display.width(), resp_line); - } - y += kLineH; - rp = nl ? nl + 1 : nullptr; - } - } else { - display.setColor(JoystickDisplay::LIGHT); - display.drawTextLeftAlign(2, y, "-"); - } - } - return (_pending != PENDING_NONE) ? 333 : 500; - } - - case STATE_CMD_INPUT: { - renderScreenHeader(display, "Command", 0, 0); - display.setTextSize(1); - char buf[ADMIN_CMD_MAX + 2]; - int pn = (_cmd_len < ADMIN_CMD_MAX) ? _cmd_len : ADMIN_CMD_MAX; - memcpy(buf, _cmd_buf, pn); - buf[pn] = '|'; buf[pn + 1] = '\0'; - display.setColor(JoystickDisplay::YELLOW); - display.drawTextEllipsized(0, kContentY, display.width(), buf); - renderT9Keypad(display, getT9KeyLabels(_cmd_kb_letters, false), - _cmd_t9_sel, kContentY + 12); - return 300; - } - - } /* switch */ - return 500; -} - -/* ===== handleInput ===== */ - -bool RepeaterAdminScreen::handleInput(char c) -{ - switch (_state) { - - case STATE_PASSWORD_ENTRY: { - if (handleT9DirectionalInput(c, _pwd_t9_sel)) return true; - if (c == KEY_ENTER) { - if (_pwd_t9_sel == 3) { - if (_pwd_len > 0) _password[--_pwd_len] = '\0'; - resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); - return true; - } - if (_pwd_t9_sel == 7) { - if (_pwd_len < ADMIN_PASSWORD_MAX - 1) { - _password[_pwd_len++] = ' '; - _password[_pwd_len] = '\0'; - } - resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); - return true; - } - if (_pwd_t9_sel == 11) { - BaseChatMesh *mesh = _task->getMesh(); - if (!mesh) { _task->showAlert("No mesh", 1500); return true; } - ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); - if (!contact) { _task->showAlert("Contact not found", 1500); return true; } - uint32_t est_timeout = 0; - int result = mesh->sendLogin(*contact, _password, est_timeout); - if (result != MSG_SEND_FAILED) { - ui_signal_tx(); - _state = STATE_LOGGING_IN; - _last_sent_at = k_uptime_get_32(); - _awaiting_tx = true; - _cmd_timeout_ms = ADMIN_TIMEOUT_MS; - if (est_timeout > 0) { - uint32_t rt = est_timeout * 2 + 3000; - if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; - } - k_timer_stop(&_timeout_timer); - k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); - } else { - _task->showAlert("Send failed", 1500); - } - resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); - return true; - } - if (_pwd_t9_sel == 12) { - _pwd_kb_letters = !_pwd_kb_letters; - resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); - return true; - } - if (_pwd_t9_sel == 15) { - resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); - goBack(); - return true; - } - const char *letters = getT9KeyLetters(_pwd_kb_letters)[_pwd_t9_sel]; - if (letters && letters[0]) { - appendOrCycleT9Char(_password, _pwd_len, ADMIN_PASSWORD_MAX, letters, - _pwd_t9_sel, _pwd_t9_last_key, _pwd_t9_letter_index, - _pwd_t9_last_press); - } - return true; - } - if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } - return false; - } - - case STATE_LOGGING_IN: - if (c == KEY_CANCEL || c == KEY_HOME) { - _awaiting_tx = false; - _state = STATE_PASSWORD_ENTRY; - return true; - } - return false; - - case STATE_SUBMENU: { - bool is_admin = (_permissions != 0); - int count = is_admin ? 6 : 4; - if (handleCommonListNavigation(c, _submenu_sel, count)) return true; - if (c == KEY_ENTER) { - if (_submenu_sel == 4 && is_admin) { - /* Commands: open free form CLI input directly */ - _cmd_len = 0; - memset(_cmd_buf, 0, sizeof(_cmd_buf)); - _cmd_t9_sel = 0; _cmd_t9_last_key = -1; - _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; - _cmd_kb_letters = true; - _hist_scroll = 0; _resp_line_scroll = 0; - _state = STATE_CMD_INPUT; - return true; - } - - if (_submenu_sel == 5 && is_admin) { - /* Time Sync: send "clock sync" CLI. The repeater reads the - * packet's sender_timestamp (set by sendCommandData() to our - * current epoch) and updates its own RTC if our time is ahead. */ - int idx; - if (_hist_count < ADMIN_HIST_MAX) { - idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; - _hist_count++; - } else { - idx = _hist_head; - _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; - } - strncpy(_hist[idx].cmd, "Time Sync", ADMIN_CMD_MAX - 1); - _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; - _hist[idx].resp[0] = '\0'; - _hist[idx].has_resp = false; - if (sendCLI("clock sync")) { - _pending = PENDING_CMD; - _state = STATE_MAIN; - _hist_scroll = 0; - } - return true; - } - - /* Items 0 through 3: Neighbours, Stats, Telemetry, Info */ - struct { - const char *label; - uint8_t req_type; /* 0 = CLI only */ - PendingKind pending; - const char *cli_cmd; /* non null means admin uses CLI */ - } dispatch[] = { - { "neighbors", 0x06, PENDING_BINARY_NEIGHBOURS, nullptr }, - { "stats", 0x01, PENDING_BINARY_STATUS, nullptr }, - { "telemetry", 0x03, PENDING_BINARY_TELEMETRY, nullptr }, - { "info", 0x07, PENDING_BINARY_OWNER_INFO, nullptr }, - }; - if (_submenu_sel < 0 || _submenu_sel > 3) { - _state = STATE_MAIN; _hist_scroll = 0; return true; - } - const auto &d = dispatch[_submenu_sel]; - - int idx; - if (_hist_count < ADMIN_HIST_MAX) { - idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; - _hist_count++; - } else { - idx = _hist_head; - _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; - } - strncpy(_hist[idx].cmd, d.label, ADMIN_CMD_MAX - 1); - _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; - _hist[idx].resp[0] = '\0'; - _hist[idx].has_resp = false; - - bool sent = false; - if (is_admin && d.cli_cmd) { - sent = sendCLI(d.cli_cmd); - if (sent) _pending = PENDING_CMD; - } else { - /* binary request — used by guests always, and by admin for telemetry/info */ - uint8_t req[7]; int req_len; - if (d.req_type == 0x06) { - req[0] = 0x06; req[1] = 0; req[2] = 10; - req[3] = 0; req[4] = 0; req[5] = 0; req[6] = 4; - req_len = 7; - } else { - req[0] = d.req_type; req_len = 1; - } - sent = sendBinaryReqHelper(_task, _contact_pubkey, req, req_len, - _cmd_timeout_ms, _awaiting_tx, _last_sent_at); - if (sent) { - _pending = d.pending; - k_timer_stop(&_timeout_timer); - k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); - } - } - - if (!sent) { - snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); - _hist[idx].has_resp = true; - } - _state = STATE_MAIN; - _hist_scroll = 0; _resp_line_scroll = 0; - return true; - } - if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } - return false; - } - - case STATE_MAIN: { - int max_scroll = (_hist_count > 1) ? _hist_count - 1 : 0; - if (c == KEY_UP) { - if (_resp_line_scroll > 0) { _resp_line_scroll--; } - else if (_hist_scroll < max_scroll) { _hist_scroll++; _resp_line_scroll = 0; } - return true; - } - if (c == KEY_DOWN) { - if (_hist_scroll > 0) { _hist_scroll--; _resp_line_scroll = 0; } - else if (_resp_line_scroll < _resp_max_line_scroll) { _resp_line_scroll++; } - return true; - } - if (c == KEY_TO_TOP) { _hist_scroll = max_scroll; _resp_line_scroll = 0; return true; } - if (c == KEY_TO_BOTTOM) { _hist_scroll = 0; _resp_line_scroll = 0; return true; } - if (c == KEY_ENTER_LONG) { - _cmd_len = 0; - memset(_cmd_buf, 0, sizeof(_cmd_buf)); - _cmd_t9_sel = 0; _cmd_t9_last_key = -1; - _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; - _cmd_kb_letters = true; - _state = STATE_CMD_INPUT; - return true; - } - if (c == KEY_CANCEL) { _state = STATE_SUBMENU; return true; } - if (c == KEY_HOME) { goBack(); return true; } - return false; - } - - case STATE_CMD_INPUT: { - if (handleT9DirectionalInput(c, _cmd_t9_sel)) return true; - if (c == KEY_ENTER) { - if (_cmd_t9_sel == 3) { - if (_cmd_len > 0) _cmd_buf[--_cmd_len] = '\0'; - resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); - return true; - } - if (_cmd_t9_sel == 7) { - if (_cmd_len < ADMIN_CMD_MAX - 1) { - _cmd_buf[_cmd_len++] = ' '; - _cmd_buf[_cmd_len] = '\0'; - } - resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); - return true; - } - if (_cmd_t9_sel == 11) { - if (_cmd_len > 0) { - /* Add entry to ring buffer */ - int idx; - if (_hist_count < ADMIN_HIST_MAX) { - idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; - _hist_count++; - } else { - idx = _hist_head; - _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; - } - strncpy(_hist[idx].cmd, _cmd_buf, ADMIN_CMD_MAX - 1); - _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; - _hist[idx].resp[0] = '\0'; - _hist[idx].has_resp = false; - if (sendCLI(_cmd_buf)) { - _pending = PENDING_CMD; - } else { - snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); - _hist[idx].has_resp = true; - } - } - _hist_scroll = 0; _resp_line_scroll = 0; - _state = STATE_MAIN; - resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); - return true; - } - if (_cmd_t9_sel == 12) { - _cmd_kb_letters = !_cmd_kb_letters; - resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); - return true; - } - if (_cmd_t9_sel == 15) { - _state = STATE_MAIN; - resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); - return true; - } - const char *letters = getT9KeyLetters(_cmd_kb_letters)[_cmd_t9_sel]; - if (letters && letters[0]) { - appendOrCycleT9Char(_cmd_buf, _cmd_len, ADMIN_CMD_MAX, letters, - _cmd_t9_sel, _cmd_t9_last_key, _cmd_t9_letter_index, - _cmd_t9_last_press); - } - return true; - } - if (c == KEY_CANCEL) { _state = STATE_MAIN; return true; } - if (c == KEY_HOME) { goBack(); return true; } - return false; - } - - } /* switch */ - return false; -} diff --git a/zephcore/helpers/ui-joystick/screens/home.cpp b/zephcore/helpers/ui-joystick/screens/home.cpp index 1fc966b..be60c5c 100644 --- a/zephcore/helpers/ui-joystick/screens/home.cpp +++ b/zephcore/helpers/ui-joystick/screens/home.cpp @@ -44,10 +44,7 @@ int HomeScreen::render(JoystickDisplay &display) if (_task->getBatteryDisplayMode() == 1) { snprintf(batt, sizeof(batt), "%.1fV", (double)batt_mv / 1000.0); } else { - int pct = ((int)batt_mv - kBattMinMv) * 100 / (kBattMaxMv - kBattMinMv); - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - snprintf(batt, sizeof(batt), "%d%%", pct); + snprintf(batt, sizeof(batt), "%u%%", _task->getCachedBattPercent()); } char icons[6] = {0}; diff --git a/zephcore/helpers/ui-joystick/screens/repeater_admin.cpp b/zephcore/helpers/ui-joystick/screens/repeater_admin.cpp new file mode 100644 index 0000000..a832644 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/repeater_admin.cpp @@ -0,0 +1,884 @@ +/* + * SPDX-License-Identifier: MIT + * + * The joystick UI's repeater / room server admin screen: login, status, + * neighbours, telemetry, owner info and a remote CLI. + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "../joystick_ui_hooks.h" +#include "screen_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* ===== RepeaterAdminScreen ===== */ + +#define ADMIN_TIMEOUT_MS 12000 + +static const int kAdminContentY = 23; +static const int kAdminVisible = 4; + +/* ===== Constructor ===== */ + +RepeaterAdminScreen::RepeaterAdminScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), + _state(STATE_PASSWORD_ENTRY), _from_contacts(false), + _permissions(0), _server_time(0), + _pwd_len(0), + _pwd_t9_sel(0), _pwd_t9_last_key(-1), _pwd_t9_letter_index(0), _pwd_t9_last_press(0), + _pwd_kb_letters(true), + _submenu_sel(0), + _pending(PENDING_NONE), + _last_sent_at(0), _cmd_timeout_ms(ADMIN_TIMEOUT_MS), _awaiting_tx(false), + _hist_head(0), _hist_count(0), _hist_scroll(0), _resp_line_scroll(0), _resp_max_line_scroll(0), + _cmd_len(0), + _cmd_t9_sel(0), _cmd_t9_last_key(-1), _cmd_t9_letter_index(0), _cmd_t9_last_press(0), + _cmd_kb_letters(true) +{ + memset(_contact_pubkey, 0, sizeof(_contact_pubkey)); + memset(_repeater_name, 0, sizeof(_repeater_name)); + memset(_password, 0, sizeof(_password)); + memset(_hist, 0, sizeof(_hist)); + memset(_cmd_buf, 0, sizeof(_cmd_buf)); + k_timer_init(&_timeout_timer, timeoutTimerCb, NULL); + k_timer_user_data_set(&_timeout_timer, this); +} + +void RepeaterAdminScreen::timeoutTimerCb(struct k_timer *t) +{ + /* ISR — wake main loop; render() detects elapsed and dispatches to onTimeout(). */ + auto *self = static_cast(k_timer_user_data_get(t)); + if (self && self->_task) self->_task->notify(); +} + +void RepeaterAdminScreen::onExit() +{ + k_timer_stop(&_timeout_timer); +} + +void RepeaterAdminScreen::onTimeout() +{ + if (_state == STATE_LOGGING_IN) { + _awaiting_tx = false; + _state = STATE_PASSWORD_ENTRY; + _task->showAlert("Login timeout", 1500); + } else if ((_state == STATE_SUBMENU || _state == STATE_MAIN || _state == STATE_CMD_INPUT) && + _pending != PENDING_NONE) { + _awaiting_tx = false; + if (_hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + snprintf(newest.resp, sizeof(newest.resp), "(no response)"); + newest.has_resp = true; + } + } + _pending = PENDING_NONE; + _task->clearAdminReqTag(); + } + _last_sent_at = 0; /* prevent re-fire from the render() elapsed-check */ +} + +/* ===== openForContact ===== */ + +void RepeaterAdminScreen::openForContact(const uint8_t *pub_key, const char *name, + bool from_contacts) +{ + if (pub_key) memcpy(_contact_pubkey, pub_key, PUB_KEY_SIZE); + else memset(_contact_pubkey, 0, PUB_KEY_SIZE); + _from_contacts = from_contacts; + strncpy(_repeater_name, name ? name : "?", sizeof(_repeater_name) - 1); + _repeater_name[sizeof(_repeater_name) - 1] = '\0'; + _admin_header_marquee_ms = k_uptime_get_32(); + + _state = STATE_PASSWORD_ENTRY; + _pwd_len = 0; + memset(_password, 0, sizeof(_password)); + _pwd_t9_sel = 0; _pwd_t9_last_key = -1; + _pwd_t9_letter_index = 0; _pwd_t9_last_press = 0; + _pwd_kb_letters = true; + + _submenu_sel = 0; + _pending = PENDING_NONE; + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + _awaiting_tx = false; + _hist_head = 0; _hist_count = 0; _hist_scroll = 0; _resp_line_scroll = 0; _resp_max_line_scroll = 0; + memset(_hist, 0, sizeof(_hist)); +} + +/* ===== sendBinaryReq — used for guest-accessible requests ===== */ + +static bool sendBinaryReqHelper(JoystickUITask *task, const uint8_t *contact_pubkey, + const uint8_t *req_data, uint8_t req_len, + uint32_t &cmd_timeout_ms, bool &awaiting_tx, + uint32_t &last_sent_at) +{ + BaseChatMesh *mesh = task->getMesh(); + if (!mesh) return false; + ContactInfo *contact = mesh->lookupContactByPubKey(contact_pubkey, PUB_KEY_SIZE); + if (!contact) { + task->showAlert("Contact lost", 1500); + return false; + } + uint32_t tag, est_timeout = 0; + int result = mesh->sendRequest(*contact, req_data, req_len, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + task->showAlert("Send failed", 1500); + return false; + } + task->registerAdminReqTag(tag); + ui_signal_tx(); + last_sent_at = k_uptime_get_32(); + awaiting_tx = true; + cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > cmd_timeout_ms) cmd_timeout_ms = rt; + } + return true; +} + +/* ===== onReqResponse — parse binary REQ responses for guest shortcuts ===== */ + +void RepeaterAdminScreen::onReqResponse(const uint8_t *pub_key_prefix, + const uint8_t *data, uint8_t data_len) +{ + if (memcmp(_contact_pubkey, pub_key_prefix, 4) != 0) return; + if (_pending != PENDING_BINARY_STATUS && _pending != PENDING_BINARY_NEIGHBOURS && + _pending != PENDING_BINARY_TELEMETRY && _pending != PENDING_BINARY_OWNER_INFO) return; + + char text[ADMIN_RESP_MAX]; + text[0] = '\0'; + + if (_pending == PENDING_BINARY_STATUS && data_len >= 4) { + /* RepeaterStats struct layout (byte offsets from start of payload): + * 0: batt_milli_volts (u16) 2: curr_tx_queue_len (u16) + * 4: noise_floor (i16) 6: last_rssi (i16) + * 8: n_packets_recv (u32) 12: n_packets_sent (u32) + * 16: total_air_time_secs (u32) 20: total_up_time_secs (u32) + * 24: n_sent_flood (u32) 28: n_sent_direct (u32) + * 32: n_recv_flood (u32) 36: n_recv_direct (u32) + * 40: err_events (u16) 42: last_snr (i16, raw×4) + * 44: n_direct_dups (u16) 46: n_flood_dups (u16) + * 48: total_rx_air_time_secs (u32) + * 52: n_recv_errors (u32) */ + uint16_t batt = 0, txq = 0, errf = 0, dup_d = 0, dup_f = 0; + int16_t noise = 0, rssi = 0, lsnr = 0; + uint32_t air_tx = 0, rx_air = 0, uptime = 0; + uint32_t ptx = 0, prx = 0; + uint32_t sent = 0, recvd = 0, sent_d = 0, recvd_d = 0, errors = 0; + if (data_len >= 2) memcpy(&batt, &data[0], 2); + if (data_len >= 4) memcpy(&txq, &data[2], 2); + if (data_len >= 6) memcpy(&noise, &data[4], 2); + if (data_len >= 8) memcpy(&rssi, &data[6], 2); + if (data_len >= 12) memcpy(&prx, &data[8], 4); + if (data_len >= 16) memcpy(&ptx, &data[12], 4); + if (data_len >= 20) memcpy(&air_tx, &data[16], 4); + if (data_len >= 24) memcpy(&uptime, &data[20], 4); + if (data_len >= 28) memcpy(&sent, &data[24], 4); + if (data_len >= 32) memcpy(&sent_d, &data[28], 4); + if (data_len >= 36) memcpy(&recvd, &data[32], 4); + if (data_len >= 40) memcpy(&recvd_d, &data[36], 4); + if (data_len >= 42) memcpy(&errf, &data[40], 2); + if (data_len >= 44) memcpy(&lsnr, &data[42], 2); + if (data_len >= 46) memcpy(&dup_d, &data[44], 2); + if (data_len >= 48) memcpy(&dup_f, &data[46], 2); + if (data_len >= 52) memcpy(&rx_air, &data[48], 4); + if (data_len >= 56) memcpy(&errors, &data[52], 4); + char up_buf[12], air_buf[12], rair_buf[12]; + formatAge(uptime, up_buf, sizeof(up_buf)); + formatAge(air_tx, air_buf, sizeof(air_buf)); + formatAge(rx_air, rair_buf, sizeof(rair_buf)); + snprintf(text, sizeof(text), + "up:%s\nbatt:%umV\ntxq:%u\nnf:%ddB\nrssi:%ddBm\nsnr:%ddB\n" + "ptx:%u\nprx:%u\nftx:%u\nfrx:%u\ndtx:%u\ndrx:%u\n" + "err:%u\nef:%04X\ndup f:%u d:%u\nair:%s\nrair:%s", + up_buf, + (unsigned)batt, (unsigned)txq, + (int)noise, (int)rssi, + (int)(lsnr / 4), + (unsigned)ptx, (unsigned)prx, + (unsigned)sent, (unsigned)recvd, + (unsigned)sent_d, (unsigned)recvd_d, + (unsigned)errors, (unsigned)errf, + (unsigned)dup_f, (unsigned)dup_d, + air_buf, rair_buf); + } else if (_pending == PENDING_BINARY_NEIGHBOURS && data_len >= 4) { + /* Neighbours response layout (after tag): + * 0..1: total_count (i16) + * 2..3: returned_count (i16) + * 4+: per entry = 4 bytes pubkey prefix + 4 bytes secs_ago + 1 byte snr */ + int16_t total = 0, returned = 0; + memcpy(&total, &data[0], 2); + memcpy(&returned, &data[2], 2); + if (returned == 0) { + snprintf(text, sizeof(text), "-none-"); + } else { + char *dp = text; + char *end = text + sizeof(text) - 1; + int offset = 4; + for (int i = 0; i < returned && offset + 9 <= data_len && dp < end - 20; i++) { + if (i > 0) *dp++ = '\n'; + char hex[12]; + snprintf(hex, sizeof(hex), "!%02X%02X%02X%02X", + data[offset], data[offset+1], data[offset+2], data[offset+3]); + uint32_t secs_ago = 0; + int8_t snr = 0; + memcpy(&secs_ago, &data[offset + 4], 4); + memcpy(&snr, &data[offset + 8], 1); + ContactInfo *nc = _task->getMesh()->lookupContactByPubKey(&data[offset], 4); + char name_buf[32]; + const char *label; + if (nc && nc->name[0]) { + _task->getDisplay().translateUTF8ToBlocks(name_buf, nc->name, sizeof(name_buf)); + label = name_buf; + } else { + label = hex; + } + char age_buf[8]; + formatAge(secs_ago, age_buf, sizeof(age_buf)); + dp += snprintf(dp, end - dp, "%s %s %+ddB", label, age_buf, (int)(snr / 4)); + offset += 9; + } + *dp = '\0'; + } + } else if (_pending == PENDING_BINARY_TELEMETRY && data_len >= 3) { + telemetry_text(data, data_len, text, sizeof(text)); + } else if (_pending == PENDING_BINARY_OWNER_INFO && data_len > 0) { + /* Plain text from repeater: firmware_version\nnode_name\nowner_info */ + char raw[ADMIN_RESP_MAX]; + int len = data_len < (int)(sizeof(raw) - 1) ? data_len : (int)(sizeof(raw) - 1); + memcpy(raw, data, len); + raw[len] = '\0'; + const char *fields[3] = { raw, "", "" }; + int fi = 0; + for (char *p = raw; *p && fi < 2; p++) { + if (*p == '\n') { *p = '\0'; fields[++fi] = p + 1; } + } + /* labels add ~19 chars, so format into a larger buffer then truncate */ + char fmt[ADMIN_RESP_MAX + 24]; + snprintf(fmt, sizeof(fmt), "FW: %s\nNode: %s\nOwner: %s", fields[0], fields[1], fields[2]); + strncpy(text, fmt, sizeof(text) - 1); + text[sizeof(text) - 1] = '\0'; + } + + if (text[0] && _hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + int len = (int)strlen(text); + if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; + memcpy(newest.resp, text, len); + newest.resp[len] = '\0'; + newest.has_resp = true; + } + } + _pending = PENDING_NONE; + _task->clearAdminReqTag(); + k_timer_stop(&_timeout_timer); +} + +/* ===== sendCLI ===== */ + +bool RepeaterAdminScreen::sendCLI(const char *cmd) +{ + BaseChatMesh *mesh = _task->getMesh(); + if (!mesh) return false; + ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); + if (!contact) { + _task->showAlert("Contact lost", 1500); + return false; + } + uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); + uint32_t est_timeout = 0; + /* TXT_TYPE_CLI_DATA, not TXT_TYPE_CLI_COMMAND: this talks to repeaters of + * any vintage, and type 3 is only understood from v1.18 on. */ + int result = mesh->sendCommandData(*contact, ts, 0, TXT_TYPE_CLI_DATA, cmd, est_timeout); + if (result == MSG_SEND_FAILED) { + _task->showAlert("Send failed", 1500); + return false; + } + ui_signal_tx(); + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = true; + /* est_timeout is one way, CLI response takes a second packet */ + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; + } + k_timer_stop(&_timeout_timer); + k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); + return true; +} + +/* ===== Callbacks ===== */ + +void RepeaterAdminScreen::onLoginResult(bool success, uint8_t permissions, uint32_t server_time) +{ + if (_state != STATE_LOGGING_IN) return; + k_timer_stop(&_timeout_timer); + if (success) { + _permissions = permissions; + _server_time = server_time; + _state = STATE_SUBMENU; + _submenu_sel = 0; + _pending = PENDING_NONE; + _hist_scroll = 0; _resp_line_scroll = 0; + _task->showAlert("Login OK", 600); + } else { + _state = STATE_PASSWORD_ENTRY; + _task->showAlert("Login failed", 1500); + } +} + +void RepeaterAdminScreen::onCliResponse(const char *text) +{ + if (!text || _pending != PENDING_CMD) return; + if (_state != STATE_MAIN && _state != STATE_CMD_INPUT && _state != STATE_SUBMENU) return; + k_timer_stop(&_timeout_timer); + if (_hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + int len = (int)strlen(text); + if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; + memcpy(newest.resp, text, len); + newest.resp[len] = '\0'; + newest.has_resp = true; + } + } + _pending = PENDING_NONE; +} + +void RepeaterAdminScreen::onPacketSent() +{ + /* RF TX completed — reset timeout clock so LBT/queue delay is excluded. + * Restart the response-timeout timer with the full window from now. */ + if (_awaiting_tx) { + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = false; + k_timer_stop(&_timeout_timer); + k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); + } +} + +/* ===== goBack ===== */ + +void RepeaterAdminScreen::goBack() +{ + if (_from_contacts) _task->gotoContactsScreen(); + else _task->gotoRepeatersScreen(); +} + +/* ===== Render helpers ===== */ + +static void renderAdminHeader(JoystickDisplay &display, const char *name, bool is_admin, + uint32_t marquee_ms) +{ + int name_w = display.width(); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + if (name_w > 0 && display.getTextWidth(name) > name_w) { + int text_len = (int)strlen(name); + if (text_len > 0) { + static const uint32_t kPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - marquee_ms; + uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)text_len); + display.drawTextEllipsized(0, 0, name_w, name + offset); + } + } else { + display.drawTextEllipsized(0, 0, name_w, name); + } + display.drawRect(0, kHeaderSepY, display.width(), 1); +} + +/* ===== render ===== */ + +int RepeaterAdminScreen::render(JoystickDisplay &display) +{ + /* Timeout check: timer fired (or any wakeup arrived past deadline). */ + if (_last_sent_at > 0 && + (k_uptime_get_32() - _last_sent_at) > _cmd_timeout_ms) { + onTimeout(); + } + + switch (_state) { + + case STATE_PASSWORD_ENTRY: { + /* Header: "Password: " on one line */ + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + const char *prefix = "Password: "; + int prefix_w = display.getTextWidth(prefix); + display.drawTextLeftAlign(0, 0, prefix); + int name_avail = display.width() - prefix_w; + if (name_avail > 0) { + int name_len = (int)strlen(_repeater_name); + if (name_len > 0 && display.getTextWidth(_repeater_name) > name_avail) { + static const uint32_t kPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; + uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)name_len); + display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name + offset); + } else { + display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name); + } + } + display.drawRect(0, kHeaderSepY, display.width(), 1); + /* Password input */ + char pwd_display[ADMIN_PASSWORD_MAX + 2]; + int n = (_pwd_len < ADMIN_PASSWORD_MAX) ? _pwd_len : ADMIN_PASSWORD_MAX; + memcpy(pwd_display, _password, n); + pwd_display[n] = '|'; pwd_display[n + 1] = '\0'; + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, kContentY, pwd_display); + renderT9Keypad(display, getT9KeyLabels(_pwd_kb_letters, true), _pwd_t9_sel, kContentY + kMenuLineH); + return 300; + } + + case STATE_LOGGING_IN: { + renderScreenHeader(display, _repeater_name, 0, 0); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 28, "Logging in..."); + uint32_t d = (k_uptime_get_32() / 333) % 3; + const char *dots[] = { ".", "..", "..." }; + display.drawTextCentered(display.width() / 2, 38, dots[d]); + return 333; + } + + case STATE_SUBMENU: { + renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); + display.setTextSize(1); + display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, + _permissions != 0 ? "Admin" : "Guest"); + static const char * const kItems[] = { "Neighbours", "Stats", "Telemetry", "Info", "Commands", "Time Sync" }; + int count = (_permissions != 0) ? 6 : 4; + int max_vis = (display.height() - kAdminContentY) / kMenuLineH; + if (max_vis < 1) max_vis = 1; + int start = computeListStart(_submenu_sel, count, max_vis); + int y = kAdminContentY; + for (int i = start; i < count && i < start + max_vis; i++) { + renderMenuListText(display, y, i == _submenu_sel, kItems[i]); + y += kMenuLineH; + } + return 500; + } + + case STATE_MAIN: { + renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); + display.setTextSize(1); + display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, + _permissions != 0 ? "Admin" : "Guest"); + if (_hist_count == 0) { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, kAdminContentY, "(no history)"); + display.drawTextLeftAlign(0, kAdminContentY + kLineH, "Long ENTER: send cmd"); + } else { + int logical = _hist_count - 1 - _hist_scroll; + if (logical < 0) logical = 0; + const CmdEntry &e = histAt(logical); + int y = kAdminContentY; + + /* Command line */ + char cmd_line[ADMIN_CMD_MAX + 12]; + if (_hist_count > 1) + snprintf(cmd_line, sizeof(cmd_line), "[%d/%d] > %s", + logical + 1, _hist_count, e.cmd); + else + snprintf(cmd_line, sizeof(cmd_line), "> %s", e.cmd); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextEllipsized(0, y, display.width(), cmd_line); + y += kLineH; + + /* Response — show all lines until display bottom */ + if (!e.has_resp && _pending != PENDING_NONE && logical == _hist_count - 1) { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(2, y, "(waiting...)"); + } else if (e.has_resp && e.resp[0]) { + /* Count lines and compute vertical scroll limits */ + int resp_lines = 1; + for (const char *p = e.resp; *p; p++) if (*p == '\n') resp_lines++; + int vis_lines = (display.height() - y) / kLineH; + _resp_max_line_scroll = (resp_lines > vis_lines) ? resp_lines - vis_lines : 0; + if (_resp_line_scroll > _resp_max_line_scroll) _resp_line_scroll = _resp_max_line_scroll; + + /* Skip lines above the scroll offset */ + const char *rp = e.resp; + for (int skip = 0; skip < _resp_line_scroll && rp; skip++) { + const char *nl = strchr(rp, '\n'); + rp = nl ? nl + 1 : nullptr; + } + + bool is_scrollable = (strcmp(e.cmd, "neighbors") == 0 || strcmp(e.cmd, "info") == 0); + uint32_t scroll_elapsed = 0; + if (is_scrollable) { + static const uint32_t kNbPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; + scroll_elapsed = (elapsed > kNbPauseMs) ? (elapsed - kNbPauseMs) : 0; + } + while (rp && *rp && y + kLineH <= display.height()) { + const char *nl = strchr(rp, '\n'); + int rlen = nl ? (int)(nl - rp) : (int)strlen(rp); + display.setColor(JoystickDisplay::GREEN); + if (is_scrollable && rlen > 0) { + char line_buf[ADMIN_RESP_MAX]; + int copy = rlen < (int)(sizeof(line_buf) - 1) ? rlen : (int)(sizeof(line_buf) - 1); + memcpy(line_buf, rp, copy); + line_buf[copy] = '\0'; + int line_w = display.width(); + if (display.getTextWidth(line_buf) > line_w) { + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)copy); + display.drawTextEllipsized(0, y, line_w, line_buf + offset); + } else { + char prefixed[ADMIN_RESP_MAX + 2]; + prefixed[0] = ' '; prefixed[1] = ' '; + memcpy(prefixed + 2, line_buf, copy); + prefixed[copy + 2] = '\0'; + display.drawTextEllipsized(0, y, line_w, prefixed); + } + } else { + char resp_line[42]; + int copy = rlen < 39 ? rlen : 39; + resp_line[0] = ' '; resp_line[1] = ' '; + memcpy(resp_line + 2, rp, copy); + resp_line[copy + 2] = '\0'; + display.drawTextEllipsized(0, y, display.width(), resp_line); + } + y += kLineH; + rp = nl ? nl + 1 : nullptr; + } + } else { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(2, y, "-"); + } + } + return (_pending != PENDING_NONE) ? 333 : 500; + } + + case STATE_CMD_INPUT: { + renderScreenHeader(display, "Command", 0, 0); + display.setTextSize(1); + char buf[ADMIN_CMD_MAX + 2]; + int pn = (_cmd_len < ADMIN_CMD_MAX) ? _cmd_len : ADMIN_CMD_MAX; + memcpy(buf, _cmd_buf, pn); + buf[pn] = '|'; buf[pn + 1] = '\0'; + display.setColor(JoystickDisplay::YELLOW); + display.drawTextEllipsized(0, kContentY, display.width(), buf); + renderT9Keypad(display, getT9KeyLabels(_cmd_kb_letters, false), + _cmd_t9_sel, kContentY + 12); + return 300; + } + + } /* switch */ + return 500; +} + +/* ===== handleInput ===== */ + +/* handleInput() in STATE_PASSWORD_ENTRY. */ +bool RepeaterAdminScreen::handlePasswordKey(char c) +{ + if (handleT9DirectionalInput(c, _pwd_t9_sel)) return true; + if (c == KEY_ENTER) { + if (_pwd_t9_sel == 3) { + if (_pwd_len > 0) _password[--_pwd_len] = '\0'; + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 7) { + if (_pwd_len < ADMIN_PASSWORD_MAX - 1) { + _password[_pwd_len++] = ' '; + _password[_pwd_len] = '\0'; + } + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 11) { + BaseChatMesh *mesh = _task->getMesh(); + if (!mesh) { _task->showAlert("No mesh", 1500); return true; } + ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); + if (!contact) { _task->showAlert("Contact not found", 1500); return true; } + uint32_t est_timeout = 0; + int result = mesh->sendLogin(*contact, _password, est_timeout); + if (result != MSG_SEND_FAILED) { + ui_signal_tx(); + _state = STATE_LOGGING_IN; + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = true; + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; + } + k_timer_stop(&_timeout_timer); + k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); + } else { + _task->showAlert("Send failed", 1500); + } + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 12) { + _pwd_kb_letters = !_pwd_kb_letters; + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 15) { + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + goBack(); + return true; + } + const char *letters = getT9KeyLetters(_pwd_kb_letters)[_pwd_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_password, _pwd_len, ADMIN_PASSWORD_MAX, letters, + _pwd_t9_sel, _pwd_t9_last_key, _pwd_t9_letter_index, + _pwd_t9_last_press); + } + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } + return false; +} + +/* handleInput() in STATE_SUBMENU. */ +bool RepeaterAdminScreen::handleSubmenuKey(char c) +{ + bool is_admin = (_permissions != 0); + int count = is_admin ? 6 : 4; + if (handleCommonListNavigation(c, _submenu_sel, count)) return true; + if (c == KEY_ENTER) { + if (_submenu_sel == 4 && is_admin) { + /* Commands: open free form CLI input directly */ + _cmd_len = 0; + memset(_cmd_buf, 0, sizeof(_cmd_buf)); + _cmd_t9_sel = 0; _cmd_t9_last_key = -1; + _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; + _cmd_kb_letters = true; + _hist_scroll = 0; _resp_line_scroll = 0; + _state = STATE_CMD_INPUT; + return true; + } + + if (_submenu_sel == 5 && is_admin) { + /* Time Sync: send "clock sync" CLI. The repeater reads the + * packet's sender_timestamp (set by sendCommandData() to our + * current epoch) and updates its own RTC if our time is ahead. */ + int idx; + if (_hist_count < ADMIN_HIST_MAX) { + idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; + _hist_count++; + } else { + idx = _hist_head; + _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; + } + strncpy(_hist[idx].cmd, "Time Sync", ADMIN_CMD_MAX - 1); + _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; + _hist[idx].resp[0] = '\0'; + _hist[idx].has_resp = false; + if (sendCLI("clock sync")) { + _pending = PENDING_CMD; + _state = STATE_MAIN; + _hist_scroll = 0; + } + return true; + } + + /* Items 0 through 3: Neighbours, Stats, Telemetry, Info */ + struct { + const char *label; + uint8_t req_type; /* 0 = CLI only */ + PendingKind pending; + const char *cli_cmd; /* non null means admin uses CLI */ + } dispatch[] = { + { "neighbors", 0x06, PENDING_BINARY_NEIGHBOURS, nullptr }, + { "stats", 0x01, PENDING_BINARY_STATUS, nullptr }, + { "telemetry", 0x03, PENDING_BINARY_TELEMETRY, nullptr }, + { "info", 0x07, PENDING_BINARY_OWNER_INFO, nullptr }, + }; + if (_submenu_sel < 0 || _submenu_sel > 3) { + _state = STATE_MAIN; _hist_scroll = 0; return true; + } + const auto &d = dispatch[_submenu_sel]; + + int idx; + if (_hist_count < ADMIN_HIST_MAX) { + idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; + _hist_count++; + } else { + idx = _hist_head; + _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; + } + strncpy(_hist[idx].cmd, d.label, ADMIN_CMD_MAX - 1); + _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; + _hist[idx].resp[0] = '\0'; + _hist[idx].has_resp = false; + + bool sent = false; + if (is_admin && d.cli_cmd) { + sent = sendCLI(d.cli_cmd); + if (sent) _pending = PENDING_CMD; + } else { + /* binary request — used by guests always, and by admin for telemetry/info */ + uint8_t req[7]; int req_len; + if (d.req_type == 0x06) { + req[0] = 0x06; req[1] = 0; req[2] = 10; + req[3] = 0; req[4] = 0; req[5] = 0; req[6] = 4; + req_len = 7; + } else { + req[0] = d.req_type; req_len = 1; + } + sent = sendBinaryReqHelper(_task, _contact_pubkey, req, req_len, + _cmd_timeout_ms, _awaiting_tx, _last_sent_at); + if (sent) { + _pending = d.pending; + k_timer_stop(&_timeout_timer); + k_timer_start(&_timeout_timer, K_MSEC(_cmd_timeout_ms), K_NO_WAIT); + } + } + + if (!sent) { + snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); + _hist[idx].has_resp = true; + } + _state = STATE_MAIN; + _hist_scroll = 0; _resp_line_scroll = 0; + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } + return false; +} + +/* handleInput() in STATE_MAIN. */ +bool RepeaterAdminScreen::handleMainKey(char c) +{ + int max_scroll = (_hist_count > 1) ? _hist_count - 1 : 0; + if (c == KEY_UP) { + if (_resp_line_scroll > 0) { _resp_line_scroll--; } + else if (_hist_scroll < max_scroll) { _hist_scroll++; _resp_line_scroll = 0; } + return true; + } + if (c == KEY_DOWN) { + if (_hist_scroll > 0) { _hist_scroll--; _resp_line_scroll = 0; } + else if (_resp_line_scroll < _resp_max_line_scroll) { _resp_line_scroll++; } + return true; + } + if (c == KEY_TO_TOP) { _hist_scroll = max_scroll; _resp_line_scroll = 0; return true; } + if (c == KEY_TO_BOTTOM) { _hist_scroll = 0; _resp_line_scroll = 0; return true; } + if (c == KEY_ENTER_LONG) { + _cmd_len = 0; + memset(_cmd_buf, 0, sizeof(_cmd_buf)); + _cmd_t9_sel = 0; _cmd_t9_last_key = -1; + _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; + _cmd_kb_letters = true; + _state = STATE_CMD_INPUT; + return true; + } + if (c == KEY_CANCEL) { _state = STATE_SUBMENU; return true; } + if (c == KEY_HOME) { goBack(); return true; } + return false; +} + +/* handleInput() in STATE_CMD_INPUT. */ +bool RepeaterAdminScreen::handleCmdInputKey(char c) +{ + if (handleT9DirectionalInput(c, _cmd_t9_sel)) return true; + if (c == KEY_ENTER) { + if (_cmd_t9_sel == 3) { + if (_cmd_len > 0) _cmd_buf[--_cmd_len] = '\0'; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 7) { + if (_cmd_len < ADMIN_CMD_MAX - 1) { + _cmd_buf[_cmd_len++] = ' '; + _cmd_buf[_cmd_len] = '\0'; + } + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 11) { + if (_cmd_len > 0) { + /* Add entry to ring buffer */ + int idx; + if (_hist_count < ADMIN_HIST_MAX) { + idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; + _hist_count++; + } else { + idx = _hist_head; + _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; + } + strncpy(_hist[idx].cmd, _cmd_buf, ADMIN_CMD_MAX - 1); + _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; + _hist[idx].resp[0] = '\0'; + _hist[idx].has_resp = false; + if (sendCLI(_cmd_buf)) { + _pending = PENDING_CMD; + } else { + snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); + _hist[idx].has_resp = true; + } + } + _hist_scroll = 0; _resp_line_scroll = 0; + _state = STATE_MAIN; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 12) { + _cmd_kb_letters = !_cmd_kb_letters; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 15) { + _state = STATE_MAIN; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + const char *letters = getT9KeyLetters(_cmd_kb_letters)[_cmd_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_cmd_buf, _cmd_len, ADMIN_CMD_MAX, letters, + _cmd_t9_sel, _cmd_t9_last_key, _cmd_t9_letter_index, + _cmd_t9_last_press); + } + return true; + } + if (c == KEY_CANCEL) { _state = STATE_MAIN; return true; } + if (c == KEY_HOME) { goBack(); return true; } + return false; +} + +bool RepeaterAdminScreen::handleInput(char c) +{ + switch (_state) { + + case STATE_PASSWORD_ENTRY: + return handlePasswordKey(c); + + case STATE_LOGGING_IN: + if (c == KEY_CANCEL || c == KEY_HOME) { + _awaiting_tx = false; + _state = STATE_PASSWORD_ENTRY; + return true; + } + return false; + + case STATE_SUBMENU: + return handleSubmenuKey(c); + + case STATE_MAIN: + return handleMainKey(c); + + case STATE_CMD_INPUT: + return handleCmdInputKey(c); + + } /* switch */ + return false; +} diff --git a/zephcore/helpers/ui-joystick/time_sync.c b/zephcore/helpers/ui-joystick/time_sync.c deleted file mode 100644 index 93f4d8f..0000000 --- a/zephcore/helpers/ui-joystick/time_sync.c +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - */ - -#include -#include -#include -#include - -static enum time_sync_source s_last = TIME_SYNC_NONE; -static int64_t s_last_uptime; /* k_uptime_get() at the last report */ - -static const char *source_short_name(enum time_sync_source src) -{ - switch (src) { - case TIME_SYNC_GPS: return "GPS"; - case TIME_SYNC_APP: return "App"; - case TIME_SYNC_WIFI: return "WiFi"; - case TIME_SYNC_CLI: return "CLI"; - case TIME_SYNC_MESH: return "Mesh"; - default: return NULL; - } -} - -void time_sync_report(enum time_sync_source src) -{ - s_last = src; - s_last_uptime = k_uptime_get(); -} - -/* Freshness-windowed source for the top-bar clock tag. Local/manual (CLI) - * and "never synced" both read as local; an external sync ages out after - * CONFIG_ZEPHCORE_UI_TIME_SOURCE_FRESH_HOURS. (Distinct from the System > - * Info > Time label below, which is not time-windowed.) */ -enum time_sync_source time_sync_get_source(void) -{ - if (s_last == TIME_SYNC_NONE || s_last == TIME_SYNC_CLI) { - return TIME_SYNC_CLI; - } - -#if CONFIG_ZEPHCORE_UI_TIME_SOURCE_FRESH_HOURS > 0 - int64_t age_ms = k_uptime_get() - s_last_uptime; - int64_t window_ms = - (int64_t)CONFIG_ZEPHCORE_UI_TIME_SOURCE_FRESH_HOURS * 3600 * 1000; - - if (age_ms > window_ms) { - return TIME_SYNC_CLI; - } -#endif - - return s_last; -} - -const char *time_sync_display_label(void) -{ - if (gps_has_time_sync()) { - return "GPS"; - } - - const char *src = source_short_name(s_last); - if (!src) { - return "None"; - } - - if (s_last != TIME_SYNC_GPS) { - return src; - } - - static char label[20]; - snprintf(label, sizeof(label), "Local (%s)", src); - return label; -} diff --git a/zephcore/helpers/ui/telemetry_text.cpp b/zephcore/helpers/ui/telemetry_text.cpp new file mode 100644 index 0000000..de6e49b --- /dev/null +++ b/zephcore/helpers/ui/telemetry_text.cpp @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: MIT + * See telemetry_text.h. + */ + +#include "telemetry_text.h" + +#include /* TELEM_CHANNEL_SELF */ +#include + +#include + +void telemetry_text(const uint8_t *data, uint8_t len, char *out, size_t cap) +{ + char *dp = out; + char *end = out + cap; + uint8_t pos = 0; + + *dp = '\0'; + while (pos + 2 < len && end - dp > 24) { + uint8_t ch = data[pos]; + uint8_t type = data[pos + 1]; + const uint8_t *v = &data[pos + 2]; + uint8_t size = LPPData::getDataSize(type); + + pos += 2; + if (ch == 0 || pos + size > len) { + break; /* end-of-data marker, or a truncated field */ + } + pos += size; + + char pre[6] = ""; + if (ch != TELEM_CHANNEL_SELF) { + snprintf(pre, sizeof(pre), "c%u ", ch); + } + const char *sep = (dp > out) ? "\n" : ""; + int n = 0; + + /* Scales and signs as the encoder (helpers/compat/CayenneLPP.h); + * LPPData's own tables miss a few (humidity scale, current sign). */ + switch (type) { + case LPP_VOLTAGE: + n = snprintf(dp, end - dp, "%s%s%s%.2fV", sep, pre, + ch == TELEM_CHANNEL_SELF ? "batt " : "", + (double)LPPData::getFloat(v, 2, 100, true)); + break; + case LPP_TEMPERATURE: + n = snprintf(dp, end - dp, "%s%stemp %.1fC", sep, pre, + (double)LPPData::getFloat(v, 2, 10, true)); + break; + case LPP_RELATIVE_HUMIDITY: + n = snprintf(dp, end - dp, "%s%shum %.1f%%", sep, pre, + (double)LPPData::getFloat(v, 1, 2, false)); + break; + case LPP_BAROMETRIC_PRESSURE: + n = snprintf(dp, end - dp, "%s%spres %.1fhPa", sep, pre, + (double)LPPData::getFloat(v, 2, 10, false)); + break; + case LPP_ALTITUDE: + n = snprintf(dp, end - dp, "%s%salt %.0fm", sep, pre, + (double)LPPData::getFloat(v, 2, 1, true)); + break; + case LPP_LUMINOSITY: + n = snprintf(dp, end - dp, "%s%slight %.0f", sep, pre, + (double)LPPData::getFloat(v, 2, 1, false)); + break; + case LPP_CURRENT: /* signed: a bidirectional monitor reports discharge < 0 */ + n = snprintf(dp, end - dp, "%s%scur %.0fmA", sep, pre, + (double)(LPPData::getFloat(v, 2, 1000, true) * 1000.0f)); + break; + case LPP_POWER: + n = snprintf(dp, end - dp, "%s%spwr %.0fW", sep, pre, + (double)LPPData::getFloat(v, 2, 1, false)); + break; + case LPP_GPS: + n = snprintf(dp, end - dp, "%s%sgps %.4f,%.4f", sep, pre, + (double)LPPData::getFloat(v, 3, 10000, true), + (double)LPPData::getFloat(v + 3, 3, 10000, true)); + break; + default: + break; /* not shown; its size was skipped above */ + } + if (n < 0 || n >= end - dp) { + break; + } + dp += n; + } + *dp = '\0'; + if (dp == out) { + snprintf(out, cap, "no telemetry"); + } +} diff --git a/zephcore/helpers/ui/telemetry_text.h b/zephcore/helpers/ui/telemetry_text.h new file mode 100644 index 0000000..26687d5 --- /dev/null +++ b/zephcore/helpers/ui/telemetry_text.h @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: MIT + * + * CayenneLPP telemetry as display text, one field per line (the joystick UI's + * repeater admin screen). Channel 1 is the node itself (battery, MCU + * temperature, GPS); every other channel is a sensor, shown with a "cN " + * prefix. Types it does not show are skipped by their size (upstream's + * LPPData), never a reason to stop. No Zephyr dependencies (host-tested). + */ + +#pragma once + +#include +#include + +/* Writes a NUL-terminated string; "no telemetry" when nothing was shown. */ +void telemetry_text(const uint8_t *data, uint8_t len, char *out, size_t cap); diff --git a/zephcore/helpers/ui-button/time_sync.c b/zephcore/helpers/ui/time_sync.c similarity index 63% rename from zephcore/helpers/ui-button/time_sync.c rename to zephcore/helpers/ui/time_sync.c index ecb35d0..db09133 100644 --- a/zephcore/helpers/ui-button/time_sync.c +++ b/zephcore/helpers/ui/time_sync.c @@ -1,6 +1,7 @@ /* * SPDX-License-Identifier: MIT - * Button UI: track which source last set the RTC, for the top-bar clock tag. + * Which source last set the RTC, for the UIs' top-bar clock tag and the + * joystick UI's System > Info > Time label. One file for both UIs. * * The tag reflects *freshness*: an external sync (GPS / app / network) is * shown only while it is recent (CONFIG_ZEPHCORE_UI_TIME_SOURCE_FRESH_HOURS). @@ -11,6 +12,7 @@ #include #include #include +#include static enum time_sync_source s_last = TIME_SYNC_NONE; static int64_t s_last_uptime; /* k_uptime_get() at the last report */ @@ -49,5 +51,39 @@ enum time_sync_source time_sync_get_source(void) } #endif - return s_last; /* GPS / APP / WIFI, still fresh */ + return s_last; /* GPS / APP / WIFI / MESH, still fresh */ +} + +static const char *source_short_name(enum time_sync_source src) +{ + switch (src) { + case TIME_SYNC_GPS: return "GPS"; + case TIME_SYNC_APP: return "App"; + case TIME_SYNC_WIFI: return "WiFi"; + case TIME_SYNC_CLI: return "CLI"; + case TIME_SYNC_MESH: return "Mesh"; + default: return NULL; + } +} + +/* The last source, not time-windowed (unlike the tag above): a GPS that + * no longer holds the clock reads "Local (GPS)". */ +const char *time_sync_display_label(void) +{ + if (gps_has_time_sync()) { + return "GPS"; + } + + const char *src = source_short_name(s_last); + if (!src) { + return "None"; + } + + if (s_last != TIME_SYNC_GPS) { + return src; + } + + static char label[20]; + snprintf(label, sizeof(label), "Local (%s)", src); + return label; } diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index 5b7c4db..c97dbd2 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -321,18 +321,15 @@ void ui_led_flash_shutdown(void) #define UI_BATT_REFRESH_MS 30000 static uint16_t (*s_batt_provider)(void); +/* The board's own percentage: its discharge curve, or the fuel gauge. */ +static uint8_t (*s_batt_pct_provider)(void); static uint32_t s_batt_last_read_ms; static bool s_batt_ever_read; -static bool (*s_power_source_provider)(void); -void ui_set_battery_provider(uint16_t (*provider)(void)) +void ui_set_battery_provider(uint16_t (*mv)(void), uint8_t (*pct)(void)) { - s_batt_provider = provider; -} - -void ui_set_power_source_provider(bool (*provider)(void)) -{ - s_power_source_provider = provider; + s_batt_provider = mv; + s_batt_pct_provider = pct; } void ui_refresh_battery(void) @@ -344,7 +341,8 @@ void ui_refresh_battery(void) if (s_batt_ever_read && (now - s_batt_last_read_ms) < UI_BATT_REFRESH_MS) { return; } - ui_set_battery(s_batt_provider(), 0); + uint16_t mv = s_batt_provider(); + ui_set_battery(mv, (mv && s_batt_pct_provider) ? s_batt_pct_provider() : 0); s_batt_last_read_ms = k_uptime_get_32(); s_batt_ever_read = true; } @@ -372,54 +370,10 @@ void ui_before_power_off(void) #endif } -/* ========== Low-battery auto-shutdown ========== - * Companion only. Driven off the existing housekeeping tick — self-throttled, - * so there is no dedicated poll. Disabled entirely (compiled out) unless a - * board sets CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0. */ -#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && \ - CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0 - -/* How often we actually sample the ADC for the shutdown check. The caller - * fires every housekeeping tick (~5 s); this gate keeps the divider from - * being energised more than necessary while still catching a sagging cell - * well before it collapses. */ -#define UI_AUTO_SHUTDOWN_CHECK_MS 30000 - -/* Consecutive below-threshold readings required before shutdown. - * 3 hits × 30 s = 90 s confirm window — a single TX-induced sag that - * lands on a check window won't trigger a false shutdown. */ -#define UI_AUTO_SHUTDOWN_CONFIRM_COUNT 3 - -/* Runtime threshold (mV); 0 disables. Seeded from the Kconfig default, then - * overridden at boot from prefs and live via the CLI (ui_set_auto_shutdown_mv). */ -static uint16_t s_auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; -static uint8_t s_low_count; - -void ui_set_auto_shutdown_mv(uint16_t mv) -{ - s_auto_shutdown_mv = mv; -} - -/* Pre-shutdown hook + deferred power-off. When the hook reports an app is - * connected (live notice queued), the power-off is deferred by a grace period - * on a work item so the main loop keeps running and delivers the message. */ -static ui_shutdown_fn s_shutdown_hook; -static bool s_shutting_down; - -static void shutdown_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - zephcore_power_off(); -} -static K_WORK_DELAYABLE_DEFINE(s_shutdown_work, shutdown_work_fn); - -void ui_set_shutdown_hook(ui_shutdown_fn fn) -{ - s_shutdown_hook = fn; -} - +/* ========== Low-battery warning screen ========== + * The policy itself is app/PowerPolicy.cpp. */ #ifdef CONFIG_ZEPHCORE_UI_DISPLAY -static void auto_shutdown_warn_screen(bool hold) +void ui_show_low_battery(bool hold) { /* Wake the panel (OLED may be blanked by auto-off; EPD is always * visible). Centre two lines; the message persists on e-paper after @@ -453,85 +407,12 @@ static void auto_shutdown_warn_screen(bool hold) k_sleep(K_MSEC(3000)); } } -#endif /* CONFIG_ZEPHCORE_UI_DISPLAY */ - -void ui_auto_shutdown_check(void) -{ - if (s_shutting_down) { - return; /* power-off already committed (deferred grace running) */ - } - if (!s_batt_provider || s_auto_shutdown_mv == 0) { - return; /* no battery provider, or runtime-disabled */ - } - - uint32_t now = k_uptime_get_32(); - static uint32_t next_check_ms; /* 0 at boot → first tick samples */ - if (next_check_ms != 0 && (now - next_check_ms) < UI_AUTO_SHUTDOWN_CHECK_MS) { - return; - } - next_check_ms = now; - - uint16_t mv = s_batt_provider(); - if (mv == 0 || mv >= s_auto_shutdown_mv) { - s_low_count = 0; - return; /* no battery hardware / reading, or healthy */ - } - - /* Don't power off while charging or USB-powered — the reading is the - * cell, not the supply, and yanking power on a bench cable is annoying. */ - if (s_power_source_provider && s_power_source_provider()) { - LOG_INF("auto-shutdown: %u mV below threshold but externally powered", mv); - s_low_count = 0; - return; - } - - s_low_count++; - LOG_WRN("auto-shutdown: battery %u mV < %u mV (%u/%u)", - mv, s_auto_shutdown_mv, s_low_count, UI_AUTO_SHUTDOWN_CONFIRM_COUNT); - if (s_low_count < UI_AUTO_SHUTDOWN_CONFIRM_COUNT) { - return; - } - - LOG_WRN("auto-shutdown: confirmed — powering off"); - - /* Let the app layer report the shutdown. If it queued a live notice to a - * connected app, it returns true and we defer the power-off by a short - * grace so the notify→fetch→send round-trip can finish; otherwise it - * persisted the reason to flash (reported on next boot) and we power off - * now. */ - bool grace = s_shutdown_hook ? s_shutdown_hook(UI_SHUTDOWN_LOW_BATTERY) - : false; - s_shutting_down = true; - -#ifdef CONFIG_POWEROFF - if (grace) { -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - auto_shutdown_warn_screen(false); /* draw, don't block the loop */ -#endif - k_work_schedule(&s_shutdown_work, K_MSEC(UI_SHUTDOWN_GRACE_MS)); - return; /* main loop keeps running → delivers the notice */ - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - auto_shutdown_warn_screen(true); /* nothing to deliver — 3 s OLED hold */ -#endif - zephcore_power_off(); #else - (void)grace; -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY - auto_shutdown_warn_screen(true); -#endif - LOG_WRN("auto-shutdown: CONFIG_POWEROFF not enabled — cannot power off"); -#endif +void ui_show_low_battery(bool hold) +{ + ARG_UNUSED(hold); } - -#else /* feature disabled (non-nRF52 / threshold default 0) */ - -void ui_set_auto_shutdown_mv(uint16_t mv) { (void)mv; } -void ui_auto_shutdown_check(void) { } -void ui_set_shutdown_hook(ui_shutdown_fn fn) { (void)fn; } - -#endif /* CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0 */ +#endif /* ========== Shared splash logo ========== * 128×13 ZephCore wordmark, MSB-first row-major (Adafruit XBM/drawBitmap diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index 0909a8e..c87156e 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -37,11 +37,6 @@ WEAK void ui_set_msg_count(uint16_t count) ARG_UNUSED(count); } -WEAK void ui_set_ble_status(bool connected, const char *name) -{ - ARG_UNUSED(connected); ARG_UNUSED(name); -} - WEAK void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, uint8_t cr, int8_t tx_power, int16_t noise_floor) @@ -142,24 +137,13 @@ WEAK void ui_set_offgrid_mode(bool enabled) ARG_UNUSED(enabled); } -WEAK void ui_set_battery_provider(uint16_t (*provider)(void)) -{ - ARG_UNUSED(provider); -} - -WEAK void ui_set_power_source_provider(bool (*provider)(void)) -{ - ARG_UNUSED(provider); -} - -WEAK void ui_set_auto_shutdown_mv(uint16_t mv) +WEAK void ui_set_battery_provider(uint16_t (*mv)(void), uint8_t (*pct)(void)) { ARG_UNUSED(mv); + ARG_UNUSED(pct); } -WEAK void ui_auto_shutdown_check(void) { } - -WEAK void ui_set_shutdown_hook(ui_shutdown_fn fn) { ARG_UNUSED(fn); } +WEAK void ui_show_low_battery(bool hold) { ARG_UNUSED(hold); } WEAK void ui_refresh_battery(void) { } WEAK void ui_invalidate_battery_cache(void) { } diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index faa6095..7a7344d 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -30,6 +30,7 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); #include "ui_task.h" #include #include "ui_mesh_actions.h" +#include "ui_radio_state.h" /* UI action bit flags — set from input thread, consumed by mesh event loop */ #define UI_ACTION_FLOOD_ADVERT BIT(0) @@ -345,24 +346,7 @@ extern "C" void mesh_housekeeping_ui_refresh(void) ui_set_clock(s_rtc_clock->getCurrentTime()); ui_set_tz(s_mesh->prefs.tz_offset); - ui_set_radio_params( - s_lora_radio->getActiveFrequencyHz(), - s_lora_radio->getActiveSpreadingFactor(), - s_lora_radio->getActiveBandwidthKHzX10(), - s_lora_radio->getActiveCodingRate(), - s_lora_radio->getConfiguredTxPower(), - s_lora_radio->getNoiseFloor()); - ui_set_radio_runtime( - s_lora_radio->getActiveSyncWord(), - s_lora_radio->getActivePreambleLength(), - s_lora_radio->isRxDutyCycleEnabled(), - s_lora_radio->isRadioReady(), - s_lora_radio->isInRecvMode(), - s_lora_radio->isTxActive()); - ui_set_radio_stats( - s_lora_radio->getPacketsRecv(), - s_lora_radio->getPacketsSent(), - s_lora_radio->getPacketsRecvErrors()); + ui_push_radio_state(*s_lora_radio); /* Update GPS satellite count even without fix. When GPS is disabled, push * a zeroed count — gps_enable(false) already zeros the internal count, but diff --git a/zephcore/helpers/ui/ui_radio_state.h b/zephcore/helpers/ui/ui_radio_state.h new file mode 100644 index 0000000..eaa35c4 --- /dev/null +++ b/zephcore/helpers/ui/ui_radio_state.h @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MIT + * + * The radio's live state into the UI: parameters, runtime flags, counters. + * One place for both boots, the companion's housekeeping refresh and the + * server loop, so the roles cannot drift apart. + */ + +#pragma once + +#include +#include "ui_task.h" + +static inline void ui_push_radio_state(mesh::LoRaRadio &radio) +{ + ui_set_radio_params(radio.getActiveFrequencyHz(), radio.getActiveSpreadingFactor(), + radio.getActiveBandwidthKHzX10(), radio.getActiveCodingRate(), + radio.getConfiguredTxPower(), radio.getNoiseFloor()); + ui_set_radio_runtime(radio.getActiveSyncWord(), radio.getActivePreambleLength(), + radio.isRxDutyCycleEnabled(), radio.isRadioReady(), + radio.isInRecvMode(), radio.isTxActive()); + ui_set_radio_stats(radio.getPacketsRecv(), radio.getPacketsSent(), + radio.getPacketsRecvErrors()); +} diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index 81f6c05..dbd6fc7 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -70,14 +70,6 @@ void ui_notify(enum ui_event event); */ void ui_set_msg_count(uint16_t count); -/** - * Update BLE connection status. - * - * @param connected true if BLE peer is connected - * @param name BLE device name (can be NULL) - */ -void ui_set_ble_status(bool connected, const char *name); - /** * Update radio parameters for display. */ @@ -207,7 +199,7 @@ void ui_set_offgrid_mode(bool enabled); * Register a battery-voltage provider used by ui_refresh_battery(). * provider() must return millivolts (0 if no battery hardware). */ -void ui_set_battery_provider(uint16_t (*provider)(void)); +void ui_set_battery_provider(uint16_t (*mv)(void), uint8_t (*pct)(void)); /** * Lazy battery refresh: re-read the ADC only if cached value is stale. @@ -217,52 +209,12 @@ void ui_set_battery_provider(uint16_t (*provider)(void)); void ui_refresh_battery(void); /** - * Register a power-source provider used by ui_auto_shutdown_check(). - * provider() must return true when the device is externally powered - * (USB/charger present), false on battery. NULL = always treat as battery. + * The low-battery warning screen, just before an auto-shutdown + * (app/PowerPolicy.cpp). hold: keep it up 3 s on an OLED (it blanks + * the moment power drops; e-paper keeps it anyway); false while the + * main loop still has a notice to deliver. Nothing without a display. */ -void ui_set_power_source_provider(bool (*provider)(void)); - -/** - * Set the runtime low-battery auto-shutdown threshold in millivolts. - * 0 disables the check. Seeded at boot from prefs (which default to - * CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) and updated live by the CLI. - * No-op on builds where the feature is compiled out (non-nRF52). - */ -void ui_set_auto_shutdown_mv(uint16_t mv); - -/* Reason codes passed to the shutdown hook. */ -#define UI_SHUTDOWN_LOW_BATTERY 1 - -/* Grace period (ms) the poweroff is deferred by when the hook asks for it - * (an app is connected and a live notice was queued), so the notify→fetch→ - * send round-trip can complete before power is cut. */ -#define UI_SHUTDOWN_GRACE_MS 1000 - -/** - * Register a pre-shutdown hook, called from ui_auto_shutdown_check() just - * before power-off. The companion uses it to report the shutdown to the - * connected app (v-contact). Return value: - * true = an app is connected and a live notice was queued — defer the - * poweroff by UI_SHUTDOWN_GRACE_MS so the app can fetch it. - * false = nothing to deliver live (persist to flash instead) — power off - * immediately. - * The hook runs on the main thread and must not block. - */ -typedef bool (*ui_shutdown_fn)(int reason); -void ui_set_shutdown_hook(ui_shutdown_fn fn); - -/** - * Low-battery auto-shutdown check (companion only). - * - * Call from the periodic housekeeping tick — it self-throttles its own ADC - * sampling, so calling it every tick is cheap (no extra polling). When - * CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS is 0 this is a no-op. Otherwise, - * if the battery is below the threshold AND not externally powered, it shows - * a brief warning (3 s on OLED, instant-persist on e-paper) and powers off - * via zephcore_power_off(). - */ -void ui_auto_shutdown_check(void); +void ui_show_low_battery(bool hold); /** * Drop the battery-refresh freshness timestamp. The next diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index bddb49c..d5da8db 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -29,6 +29,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include "ui_task.h" +#include "ui_radio_state.h" #include "ui_mesh_actions.h" #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) #include "display.h" @@ -67,6 +68,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include +#include #endif /* Without this a BLE controller assert freezes the CPU at top IRQ priority @@ -90,7 +92,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #ifdef ZEPHCORE_LORA static void save_prefs_to_flash(void); -static void vcontact_battery_alert_check(void); +static void power_policy_tick(void); #endif #define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ @@ -406,13 +408,9 @@ static void mesh_event_loop(void) companion_mesh_ptr->timeSyncTick(); } - /* Low-battery auto-shutdown; compiled out unless the board - * sets a threshold. */ - ui_auto_shutdown_check(); - - /* The v-contact low-battery alert, separate because the - * shutdown check is a no-op on headless builds. */ - vcontact_battery_alert_check(); + /* Low-battery auto-shutdown and the v-contact alert, UI or + * not (app/PowerPolicy.cpp). */ + power_policy_tick(); } /* Prefs changed off-main: the flash write happens here, and posts @@ -559,66 +557,48 @@ static void radio_reconfigure(bool preset_changed) #define ZEPHCORE_HAS_AUTO_SHUTDOWN 0 #endif -#if ZEPHCORE_HAS_AUTO_SHUTDOWN -/* `get|set autoshutdown`: companion-only, so not in CommonCLI. True if the - * line was one. */ -static bool handle_autoshutdown_cli(const char *line, char *reply) +/* ========== Low-battery policy (app/PowerPolicy.cpp) ========== + * Auto-shutdown and the v-contact alert, on every companion that reads a + * battery. These hooks are its only v-contact side. */ + +/* Just before a low-battery power-off: with an app connected, send the notice + * live (the policy then waits a short grace). Otherwise the policy stores the + * reason for the next boot, since the offline queue does not survive System + * OFF. */ +static bool power_notify_shutdown(void) { - if (strcmp(line, "get autoshutdown") == 0) { - uint16_t mv = companion_mesh.prefs.auto_shutdown_mv; - if (mv == 0) { - strcpy(reply, "autoshutdown: off"); - } else { - snprintf(reply, CLI_REPLY_SIZE, "autoshutdown: %u mV", mv); - } - return true; - } - if (strncmp(line, "set autoshutdown ", 17) == 0) { - const char *arg = line + 17; - while (*arg == ' ') { - arg++; - } - /* Digits only, then nothing but whitespace. */ - char *end = NULL; - long v = strtol(arg, &end, 10); - while (*end == ' ' || *end == '\r' || *end == '\n' || *end == '\t') { - end++; - } - if (arg[0] < '0' || arg[0] > '9' || *end != '\0') { - strcpy(reply, "ERROR: numbers only (0 = off, 1-5000 mV)"); - return true; - } - if (v > 5000) { - strcpy(reply, "ERROR: must be 0 (off) or 1-5000 mV"); - return true; - } - companion_mesh.prefs.auto_shutdown_mv = (uint16_t)v; - /* Coalesced flash write from the event loop. */ - k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY); - ui_set_auto_shutdown_mv((uint16_t)v); - if (v == 0) { - strcpy(reply, "OK - autoshutdown off"); - } else { - snprintf(reply, CLI_REPLY_SIZE, "OK - autoshutdown %ld mV", v); - } + if (companion_transport_up() && companion_mesh.isVContactEnabled()) { + companion_mesh.vcontactNotify("Powering off: low battery"); return true; } return false; } -#endif /* ZEPHCORE_HAS_AUTO_SHUTDOWN */ -static void vcontact_battery_alert_rearm(void); - -/* The v-contact battery-alert threshold in mV, 0 = off. The 0xFFFF default is - * 200 mV above the auto-shutdown cutoff, so the alert beats the 90 s shutdown - * confirm window; 3500 mV on boards without a cutoff. */ -static uint16_t vcontact_battery_alert_threshold_mv(void) +static void power_battery_alert(uint16_t mv, uint16_t threshold_mv) { - uint16_t pref = companion_mesh.prefs.v_battery_alert_mv; - if (pref == 0) return 0; - if (pref != 0xFFFF) return pref; - uint16_t cutoff = companion_mesh.prefs.auto_shutdown_mv; - return cutoff ? (uint16_t)(cutoff + 200) : 3500; + char msg[48]; + + snprintf(msg, sizeof(msg), "Battery low: %u mV (alert at %u mV)", mv, threshold_mv); + companion_mesh.vcontactNotify(msg); +} + +static bool power_alert_enabled(void) +{ + return companion_mesh.isVContactEnabled(); +} + +static void power_prefs_dirty(void) +{ + k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY); +} + +static PowerPolicy power_policy(zephyr_board, companion_mesh.prefs, + {power_notify_shutdown, power_battery_alert, power_alert_enabled, + power_prefs_dirty}); + +static void power_policy_tick(void) +{ + power_policy.tick(); } /* The `v.*` commands (v-contact settings) and `help`. True if the line was one. */ @@ -627,12 +607,14 @@ static bool handle_vcontact_cli(const char *line, char *reply) if (strcmp(line, "help") == 0 || strcmp(line, "?") == 0) { /* There is no global help: list the companion extras only. */ strcpy(reply, - "Companion extras (standard set/get commands also work):\r\n" + /* Short: the whole reply must fit one companion frame (the + * padded table this replaced lost its last line). */ + "Companion extras (get|set):\r\n" #if ZEPHCORE_HAS_AUTO_SHUTDOWN - " get|set autoshutdown - low-batt cutoff, 0 = off\r\n" + " autoshutdown , 0 = off\r\n" #endif - " get|set v.contact on|off - loopback admin contact\r\n" - " get|set v.batteryalert - 0 = off, or default"); + " v.contact on|off\r\n" + " v.batteryalert |0|default"); return true; } if (strcmp(line, "get v.contact") == 0) { @@ -671,7 +653,7 @@ static bool handle_vcontact_cli(const char *line, char *reply) strcpy(reply, "v.batteryalert: off"); } else if (pref == 0xFFFF) { snprintf(reply, CLI_REPLY_SIZE, "v.batteryalert: default (%u mV)", - vcontact_battery_alert_threshold_mv()); + power_policy.alertThresholdMv()); } else { snprintf(reply, CLI_REPLY_SIZE, "v.batteryalert: %u mV", pref); } @@ -698,12 +680,12 @@ static bool handle_vcontact_cli(const char *line, char *reply) } companion_mesh.prefs.v_battery_alert_mv = v; k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY); - vcontact_battery_alert_rearm(); + power_policy.rearmAlert(); if (v == 0) { strcpy(reply, "OK - v.batteryalert off"); } else if (v == 0xFFFF) { snprintf(reply, CLI_REPLY_SIZE, "OK - v.batteryalert default (%u mV)", - vcontact_battery_alert_threshold_mv()); + power_policy.alertThresholdMv()); } else { snprintf(reply, CLI_REPLY_SIZE, "OK - v.batteryalert %u mV", v); } @@ -712,26 +694,6 @@ static bool handle_vcontact_cli(const char *line, char *reply) return false; } -/* Called just before a low-battery power-off. With an app connected, send a - * v-contact notice and return true (the UI waits a short grace for it). - * Otherwise store the reason in flash, reported on the next boot, since the - * offline queue does not survive System OFF, and return false. */ -static bool companion_shutdown_hook(int reason) -{ - const char *msg = (reason == UI_SHUTDOWN_LOW_BATTERY) - ? "Powering off: low battery" - : "Powering off"; - - bool connected = companion_transport_up(); - - if (connected && companion_mesh.isVContactEnabled()) { - companion_mesh.vcontactNotify(msg); - return true; /* deliver live — ask the UI for the grace delay */ - } - - zephcore_shutdown_reason_save(ZC_SHUTDOWN_LOW_VOLTAGE); - return false; /* nobody listening — flash marker, power off now */ -} /* The CompanionMesh::handleCommand callback (main thread). The v-contact and * autoshutdown commands never see a remote sender: handleCommand stops those. */ @@ -745,11 +707,9 @@ static void companion_cli_exec(const char *line, uint32_t sender_timestamp, if (handle_vcontact_cli(line, reply)) { return; } -#if ZEPHCORE_HAS_AUTO_SHUTDOWN - if (handle_autoshutdown_cli(line, reply)) { + if (power_policy.handleCommand(line, reply, CLI_REPLY_SIZE)) { return; } -#endif #if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) if (companion_wifi_cli(line, companion_mesh.prefs, save_prefs_to_flash, reply)) { return; @@ -759,63 +719,6 @@ static void companion_cli_exec(const char *line, uint32_t sender_timestamp, companion_cli.handleCommand(sender_timestamp, line, reply); } -/* ========== V-contact battery alert ========== - * Not in ui_auto_shutdown_check(), which is a no-op on headless builds; same - * pattern: a 30 s sample gate on the housekeeping tick, three strikes so a TX - * sag cannot trigger it, skipped on external power. Once per discharge; it - * re-arms at threshold + 150 mV or on a threshold change. */ -static bool vcontact_batt_latched; -static uint8_t vcontact_batt_low_count; - -static void vcontact_battery_alert_rearm(void) -{ - vcontact_batt_latched = false; - vcontact_batt_low_count = 0; -} - -static void vcontact_battery_alert_check(void) -{ - if (!companion_mesh.isVContactEnabled()) { - return; - } - uint16_t thresh = vcontact_battery_alert_threshold_mv(); - if (thresh == 0) { - return; - } - - uint32_t now = k_uptime_get_32(); - static uint32_t next_check_ms; /* 0 at boot → first tick samples */ - if (next_check_ms != 0 && (now - next_check_ms) < 30000) { - return; - } - next_check_ms = now; - - uint16_t mv = zephyr_board.getBattMilliVolts(); - if (mv == 0) { /* no battery hardware / no reading */ - vcontact_batt_low_count = 0; - return; - } - if (zephyr_board.isExternalPowered() || mv >= thresh + 150) { - /* charging or recovered — re-arm for the next discharge cycle */ - vcontact_battery_alert_rearm(); - return; - } - if (mv >= thresh) { - vcontact_batt_low_count = 0; - return; - } - if (vcontact_batt_latched) { - return; - } - if (++vcontact_batt_low_count < 3) { - return; - } - vcontact_batt_latched = true; - char msg[48]; - snprintf(msg, sizeof(msg), "Battery low: %u mV (alert at %u mV)", mv, thresh); - companion_mesh.vcontactNotify(msg); -} - #if ZEPHCORE_USB_STACK /* A USB text-CLI line. The reply is framed like the repeater's serial CLI * ("\r\n -> \r\n") so web serial consoles render it the same. */ @@ -1070,10 +973,7 @@ int main(void) companion_mesh.setBatteryCallback(get_battery_mv); companion_mesh.setBoard(&zephyr_board); - ui_set_battery_provider(get_battery_mv); - ui_set_power_source_provider([]() { return zephyr_board.isExternalPowered(); }); - ui_set_auto_shutdown_mv(companion_mesh.prefs.auto_shutdown_mv); - ui_set_shutdown_hook(companion_shutdown_hook); + ui_set_battery_provider(get_battery_mv, []() { return zephyr_board.getBattPercent(); }); companion_mesh.setRadioReconfigureCallback(radio_reconfigure); companion_mesh.setPinChangeCallback([](uint32_t new_pin) { #if IS_ENABLED(CONFIG_BT) @@ -1106,24 +1006,8 @@ int main(void) #endif ui_set_node_name(companion_mesh.prefs.node_name); - ui_set_radio_params( - lora_radio.getActiveFrequencyHz(), - lora_radio.getActiveSpreadingFactor(), - lora_radio.getActiveBandwidthKHzX10(), - lora_radio.getActiveCodingRate(), - lora_radio.getConfiguredTxPower(), - lora_radio.getNoiseFloor()); - ui_set_radio_runtime( - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); - ui_set_radio_stats(lora_radio.getPacketsRecv(), - lora_radio.getPacketsSent(), - lora_radio.getPacketsRecvErrors()); - ui_set_battery(zephyr_board.getBattMilliVolts(), 0); + ui_push_radio_state(lora_radio); + ui_set_battery(zephyr_board.getBattMilliVolts(), zephyr_board.getBattPercent()); ui_set_gps_available(gps_is_available()); ui_set_gps_enabled(companion_mesh.prefs.gps_enabled != 0); ui_set_ble_enabled(companion_mesh.prefs.ble_disabled != 1); /* BLE starts advertising at boot */ @@ -1191,13 +1075,7 @@ int main(void) LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n); } } - ui_set_radio_runtime( - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); + ui_push_radio_state(lora_radio); /* side detectors changed the runtime state */ /* ADC multiplier override (0 = devicetree default) */ zephyr_board.setAdcMultiplier(companion_mesh.prefs.adc_multiplier); diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index 4f59642..8dbd47d 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -61,6 +61,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) /* UI subsystem (display, buttons, buzzer) */ #include "ui_task.h" +#include "ui_radio_state.h" #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) #include "display.h" #endif @@ -467,26 +468,7 @@ static void refresh_ui_radio_state(void) return; } - ui_set_radio_params( - lora_radio.getActiveFrequencyHz(), - lora_radio.getActiveSpreadingFactor(), - lora_radio.getActiveBandwidthKHzX10(), - lora_radio.getActiveCodingRate(), - lora_radio.getConfiguredTxPower(), - lora_radio.getNoiseFloor()); - - ui_set_radio_runtime( - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); - - ui_set_radio_stats( - lora_radio.getPacketsRecv(), - lora_radio.getPacketsSent(), - lora_radio.getPacketsRecvErrors()); + ui_push_radio_state(lora_radio); } #endif @@ -814,8 +796,8 @@ int server_main(const ServerRole &role) /* Feed initial UI state from loaded prefs */ ui_set_node_name(prefs->node_name); refresh_ui_radio_state(); - ui_set_battery_provider(get_battery_mv); - ui_set_battery(zephyr_board.getBattMilliVolts(), 0); + ui_set_battery_provider(get_battery_mv, []() { return zephyr_board.getBattPercent(); }); + ui_set_battery(zephyr_board.getBattMilliVolts(), zephyr_board.getBattPercent()); ui_set_gps_available(gps_is_available()); /* Defer initial advertisement by 10s — gives GPS time for a quick fix.