feat(companion): core parity -- L1 sends via the core, shared telemetry, schema settings on L1

- L1's MessagesScreen sends through UiCore::sendDirectText / sendChannelText
  (its own copy of send + file + ACK tracking gone).
- ui-core/Telemetry.h: the dashboard fields (numbers, names, formats) and
  sensor readings as text, each frontend passing its Style; L2 Home, L1's
  clock page, lock screen, sensors page and GPS altitude use it.
- L2: the side button held in the first 8 s is the CLI rescue, as on L1.
- L1's repeater radio goes through rptctl.
- L1 Settings are built from SettingsSchema: short labels for small
  screens, SCHEMA_* placeholders expand to the schema's sections, one
  generic row (switch ON/OFF, bars for brightness / volume). 20 hand-written
  rows and their tables gone; L1 implements applyDisplayPrefs /
  applySoundPrefs. settings::text / step / indexOf shared with L2.
- Schema: auto-off gains 5 s; the advert sound scope reads "Direct".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-28 23:19:47 +02:00
co-authored by Claude Opus 5.5
parent 0a669e1fd7
commit b355922d32
12 changed files with 375 additions and 686 deletions
@@ -104,7 +104,7 @@ static void fmtSecs(char* b, int n, int32_t s, const char* zero) {
else if (s < 3600) snprintf(b, n, "%ld min", (long)(s / 60));
else snprintf(b, n, "%ld h", (long)(s / 3600));
}
static const int32_t AUTO_OFF[] = { 15, 30, 60, 120, 300, 0 };
static const int32_t AUTO_OFF[] = { 5, 15, 30, 60, 120, 300, 0 };
static void optAutoOff(uint8_t v, char* b, int n, const NodePrefs&) { fmtSecs(b, n, AUTO_OFF[v], "Never"); }
static const int32_t GPS_DUTY[] = { 0, 60, 300, 900, 1800, 3600 };
static void optGpsDuty(uint8_t v, char* b, int n, const NodePrefs&) { fmtSecs(b, n, GPS_DUTY[v], "Always on"); }
@@ -142,7 +142,7 @@ static void optHour(uint8_t v, char* b, int n, const NodePrefs& p) { // "22:00
}
static void optSound(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", soundctl::soundLabel(v)); }
static void optAdvertScope(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct only" : "All");
snprintf(b, n, "%s", v == ADVERT_SOUND_SCOPE_ZERO_HOP ? "Direct" : "All");
}
// ── Side effects ──────────────────────────────────────────────────────────────
@@ -222,13 +222,40 @@ static const Setting ALL[] = {
IDX("Adverts", "A node announcing itself", SEC_SOUND_FOR, notif_melody_ad, soundctl::SOUND_COUNT, optSound, nullptr),
IDX("Advert sound for", "Direct: no repeaters", SEC_SOUND_FOR, advert_sound_scope, 2, optAdvertScope, nullptr),
};
static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0]));
// The label on a small screen (ui-new: about ten characters beside an
// eight-character value); a setting not listed uses its own.
static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = {
{ NP_OFF(display_brightness), "Bright" }, { NP_OFF(auto_off_secs), "Auto off" },
{ NP_OFF(msg_wake_screen_off), "Msg wake" }, { NP_OFF(auto_lock), "Auto lock" },
{ NP_OFF(batt_display_mode), "Batt disp" }, { NP_OFF(low_batt_mv), "Low batt" },
{ NP_OFF(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" },
{ NP_OFF(clock_hide_seconds), "Seconds" }, { NP_OFF(units_imperial), "Imperial" },
{ NP_OFF(dm_resend_count), "Resend" }, { NP_OFF(contact_expiry_idx), "Expire" },
{ NP_OFF(fav_sort_off), "Favs top" }, { NP_OFF(buzzer_volume), "Buzzer vol" },
{ NP_OFF(quiet_hours), "Quiet hrs" }, { NP_OFF(quiet_from), " from" },
{ NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" },
{ NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" },
{ NP_OFF(advert_sound_scope), "AD scope" },
};
static const char* shortLabel(const Setting& s) {
for (auto& l : SHORT_LABELS) if (l.offset == s.offset) return l.text;
return s.label;
}
// A switch: two values, no labels of its own (on / off, however it's stored).
static bool isSwitch(const Setting& s) { return !s.option && s.count == 2; }
// The index of the setting over NodePrefs field `offset`; -1 if none.
static int indexOf(uint16_t offset) {
for (int i = 0; i < COUNT; i++) if (ALL[i].offset == offset) return i;
return -1;
}
#undef IDX
#undef SW
#undef MAP
#undef COUNT_OF
#undef NP_SIZE
#undef NP_OFF
static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0]));
static int32_t readRaw(const NodePrefs& p, const Setting& s) {
const uint8_t* f = (const uint8_t*)&p + s.offset;
@@ -262,5 +289,18 @@ static void set(NodePrefs& p, const Setting& s, uint8_t v) {
if (v >= s.count) return;
writeRaw(p, s, s.values ? s.values[v] : v);
}
// The current value as text ("UTC+2", "3.4 V"); a switch as `on` / `off`.
static const char* text(const NodePrefs& p, const Setting& s, char* buf, int n,
const char* on = "On", const char* off = "Off") {
uint8_t v = get(p, s);
if (s.option) s.option(v, buf, n, p);
else snprintf(buf, n, "%s", v ? on : off);
return buf;
}
// One step along the values, wrapping; then the setting's side effect.
static void step(NodePrefs& p, const Setting& s, int dir, UiCore& core) {
set(p, s, (uint8_t)((get(p, s) + s.count + dir) % s.count));
if (s.changed) s.changed(core);
}
} // namespace settings
@@ -0,0 +1,138 @@
#pragma once
// Telemetry as text: the fields a frontend's clock page shows
// (NodePrefs::dashboard_fields -- Home on L2, the clock page and lock screen
// on L1) and any sensor reading (L1's sensors page). One set of numbers,
// names and formats; each frontend passes its own Style (the degree glyph in
// its font, how much room it has).
#include <stdint.h>
#include <stdio.h>
#include <helpers/sensors/LPPDataHelpers.h>
#include <helpers/sensors/LocationProvider.h>
#include "Battery.h"
namespace telemetry {
// Stored in NodePrefs::dashboard_fields: append only.
enum Field : uint8_t { NONE, BATT_V, TEMP, HUM, PRES, GPS, ALT, LUX, CO2, NODES, MSGS,
BATT_PCT, SATS, ALT_GPS, COUNT };
static const char* const NAME[COUNT] = {
"None", "Battery (V)", "Temperature", "Humidity", "Pressure", "Position", "Altitude (sensor)",
"Light", "CO2", "Contacts", "Unread", "Battery (%)", "Satellites", "Altitude (GPS)",
};
// The same, for a narrow menu column (L1).
static const char* const COMPACT[COUNT] = {
"None", "Batt V", "Temp", "Humidity", "Pressure", "GPS", "Altitude (Baro)", "Lux", "CO2", "Contacts",
"Messages", "Batt %", "Sats", "Altitude (GPS)",
};
// A label beside the value, where room is short.
static const char* const LABEL[COUNT] = {
"", "Batt", "Temp", "Hum", "Pres", "GPS", "Alt", "Lux", "CO2", "Nodes", "Msgs", "Batt", "Sats", "AltG",
};
struct Style {
const char* deg; // the degree sign in the display's font
bool spaced; // "12 m" rather than "12m"
bool nouns; // counts say what they count ("3 nodes"): a value without its label
uint8_t gps_dp; // decimals of a position
};
// What the values are read from; the frontend fills in what it has.
struct Inputs {
uint16_t batt_mv = 0, low_batt_mv = 0;
int nodes = 0;
int unread = 0;
bool unread_more = false; // some unread already gone from the rings: "3+"
bool imperial = false;
LocationProvider* loc = nullptr; // nullptr: no GPS
bool gps_on = true;
const uint8_t* lpp = nullptr; // sensors on the bus, as one querySensors() left them
uint8_t lpp_len = 0;
};
static uint8_t lppType(uint8_t f) {
switch (f) {
case TEMP: return LPP_TEMPERATURE;
case HUM: return LPP_RELATIVE_HUMIDITY;
case PRES: return LPP_BAROMETRIC_PRESSURE;
case ALT: return LPP_ALTITUDE;
case LUX: return LPP_LUMINOSITY;
case CO2: return LPP_CONCENTRATION;
}
return 0;
}
// Read from the sensors, not the radio or GPS.
static bool isSensor(uint8_t f) { return lppType(f) != 0; }
// Altitude always in the small unit (never km / mi).
static void altText(float m, bool imperial, const Style& st, char* v, int n) {
snprintf(v, n, "%.0f%s%s", imperial ? m * 3.28084f : m, st.spaced ? " " : "", imperial ? "ft" : "m");
}
// The reading `r` is at (its header read) as text; false, and the data
// skipped, for a type that isn't shown.
static bool lppText(LPPReader& r, uint8_t type, bool imperial, const Style& st, char* v, int n) {
const char* sp = st.spaced ? " " : "";
float x, y, z;
switch (type) {
case LPP_TEMPERATURE:
r.readTemperature(x);
snprintf(v, n, "%.1f%s%s%s", imperial ? x * 9 / 5 + 32 : x, sp, st.deg, imperial ? "F" : "C");
return true;
case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(x); snprintf(v, n, "%.0f%%", x); return true;
case LPP_BAROMETRIC_PRESSURE: r.readPressure(x); snprintf(v, n, "%.0f%shPa", x, sp); return true;
case LPP_ALTITUDE: r.readAltitude(x); altText(x, imperial, st, v, n); return true;
case LPP_LUMINOSITY: r.readLuminosity(x); snprintf(v, n, "%.0f%slx", x, sp); return true;
case LPP_CONCENTRATION: r.readConcentration(x); snprintf(v, n, "%.0f%sppm", x, sp); return true;
case LPP_VOLTAGE: r.readVoltage(x); snprintf(v, n, "%.2f%sV", x, sp); return true;
case LPP_CURRENT: r.readCurrent(x); snprintf(v, n, "%.3f%sA", x, sp); return true;
case LPP_POWER: r.readPower(x); snprintf(v, n, "%.1f%sW", x, sp); return true;
case LPP_PERCENTAGE: r.readPercentage(x); snprintf(v, n, "%.0f%%", x); return true;
case LPP_DISTANCE: r.readDistance(x); snprintf(v, n, "%.2f%sm", x, sp); return true;
case LPP_GPS:
r.readGPS(x, y, z);
if (x != 0 || y != 0) snprintf(v, n, "%.4f %.4f", x, y);
else snprintf(v, n, "--");
return true;
}
r.skipData(type);
return false;
}
// Field `f` as text; "--" when there's nothing to show.
static void text(uint8_t f, const Inputs& in, const Style& st, char* v, int n) {
const char* sp = st.spaced ? " " : "";
snprintf(v, n, "--");
bool fix = in.loc && in.loc->isValid();
switch (f) {
case BATT_V:
if (in.batt_mv) snprintf(v, n, "%u.%02u%sV", in.batt_mv / 1000, (in.batt_mv % 1000) / 10, sp);
return;
case BATT_PCT:
if (in.batt_mv) snprintf(v, n, "%d%%", battery::percent(in.batt_mv, in.low_batt_mv));
return;
case NODES: snprintf(v, n, "%d%s", in.nodes, st.nouns ? " nodes" : ""); return;
case MSGS: snprintf(v, n, "%d%s%s", in.unread, in.unread_more ? "+" : "", st.nouns ? " msgs" : ""); return;
case SATS:
if (in.loc && in.gps_on) snprintf(v, n, "%ld%s", (long)in.loc->satellitesCount(), st.nouns ? " sats" : "");
return;
case GPS:
if (fix) snprintf(v, n, "%.*f %.*f", st.gps_dp, in.loc->getLatitude() / 1e6, st.gps_dp, in.loc->getLongitude() / 1e6);
else if (in.loc) snprintf(v, n, "no fix");
return;
case ALT_GPS:
if (fix) altText(in.loc->getAltitude() / 1000.0f, in.imperial, st, v, n);
else if (in.loc) snprintf(v, n, "no fix");
return;
}
uint8_t want = lppType(f);
if (!want || !in.lpp) return;
LPPReader r(in.lpp, in.lpp_len);
uint8_t ch, type;
while (r.readHeader(ch, type)) {
if (type == want) { lppText(r, type, in.imperial, st, v, n); return; }
r.skipData(type);
}
}
} // namespace telemetry