ui: admin telemetry decoder, battery % from board curves, PowerPolicy

- admin screen: telemetry via upstream LPPDataHelpers (ported; fixed its
  1-byte humidity size), every field, per-channel, signed current
  (helpers/ui/telemetry_text)
- battery %: both UIs show ZephyrBoard::getBattPercent() (discharge
  curve / fuel gauge) instead of a linear 3.0-4.2 V estimate
- app/PowerPolicy: low-battery auto-shutdown + v-contact alert +
  autoshutdown CLI on every companion (headless too); under 2000 mV
  counts as no battery; default 3200 mV, stored 3300 migrated once
- one time_sync.c for both UIs; ui_push_radio_state() for all roles
- repeater admin screen in its own file; handleInput split per mode
- dead UI code removed; companion help fits one reply frame
This commit is contained in:
liquidraver
2026-09-25 20:49:48 +02:00
parent d5bd805710
commit 059022403b
34 changed files with 2031 additions and 1765 deletions
+1 -1
View File
@@ -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
+19 -7
View File
@@ -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 <mV>`, 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 <sec>`
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 <epoch>`, `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
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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;
@@ -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;
}
+207
View File
@@ -0,0 +1,207 @@
/*
* SPDX-License-Identifier: MIT
* See PowerPolicy.h.
*/
#include "PowerPolicy.h"
#include <adapters/board/zephyr_poweroff.h>
#include <helpers/ui/ui_task.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+73
View File
@@ -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 <mesh/MeshCore.h>
#include <helpers/NodePrefs.h>
#include <stddef.h>
#include <stdint.h>
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;
};
+18
View File
@@ -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<uint8_t>(p->buzzer_quiet, 0);
p->gps_enabled = saneBool<uint8_t>(p->gps_enabled, 0);
p->gps_enabled_set = saneBool<uint8_t>(p->gps_enabled_set, 0);
p->auto_shutdown_set = saneBool<uint8_t>(p->auto_shutdown_set, 0);
p->rx_duty_cycle = saneBool<uint8_t>(p->rx_duty_cycle, 0);
p->leds_disabled = saneBool<uint8_t>(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
+5
View File
@@ -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;
+292
View File
@@ -0,0 +1,292 @@
#pragma once
#include <stdint.h>
#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; }
};
+4 -1
View File
@@ -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
+4 -30
View File
@@ -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 :
-5
View File
@@ -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;
+4 -18
View File
@@ -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();
@@ -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 */
@@ -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 */
@@ -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) {}
@@ -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));
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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};
@@ -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 <helpers/BaseChatMesh.h>
#include <mesh/Utils.h>
#include <helpers/ContactInfo.h>
#include <helpers/ui/telemetry_text.h>
#include <helpers/ui/ui_task.h>
#include <zephyr/kernel.h>
#include <string.h>
#include <stdio.h>
/* ===== 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<RepeaterAdminScreen *>(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: <scrolling repeater name>" 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;
}
-72
View File
@@ -1,72 +0,0 @@
/*
* SPDX-License-Identifier: MIT
*/
#include <time_sync.h>
#include <adapters/gps/ZephyrGPSManager.h>
#include <zephyr/kernel.h>
#include <stdio.h>
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;
}
+92
View File
@@ -0,0 +1,92 @@
/*
* SPDX-License-Identifier: MIT
* See telemetry_text.h.
*/
#include "telemetry_text.h"
#include <helpers/SensorManager.h> /* TELEM_CHANNEL_SELF */
#include <helpers/sensors/LPPDataHelpers.h>
#include <stdio.h>
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");
}
}
+17
View File
@@ -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 <stddef.h>
#include <stdint.h>
/* 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);
@@ -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 <time_sync.h>
#include <adapters/gps/ZephyrGPSManager.h>
#include <zephyr/kernel.h>
#include <stdio.h>
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;
}
+14 -133
View File
@@ -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
+3 -19
View File
@@ -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) { }
+2 -18
View File
@@ -30,6 +30,7 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL);
#include "ui_task.h"
#include <joystick_ui_hooks.h>
#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
+24
View File
@@ -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 <adapters/radio/LoRaRadio.h>
#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());
}
+6 -54
View File
@@ -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
+55 -177
View File
@@ -29,6 +29,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <ZephyrSensorManager.h>
#include <helpers/time_sync.h>
#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 <app/CompanionCLI.h>
#include <helpers/battery_curve.h>
#include <zephyr_poweroff.h>
#include <app/PowerPolicy.h>
#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 <mV> - low-batt cutoff, 0 = off\r\n"
" autoshutdown <mV>, 0 = off\r\n"
#endif
" get|set v.contact on|off - loopback admin contact\r\n"
" get|set v.batteryalert <mV> - 0 = off, or default");
" v.contact on|off\r\n"
" v.batteryalert <mV>|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 -> <reply>\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);
+4 -22
View File
@@ -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.