vcontact p1

This commit is contained in:
liquidraver committed 2026-07-11 16:28:27 +02:00
1 parent 748e7917e2
commit 0e7a9b7285
6 files changed
+581 -30

No files matched your search

+48
View File
@@ -476,6 +476,54 @@ Handles the binary BLE protocol with ~50 command opcodes. Key features:
- **Ed25519 signing**: 3-phase flow (start→data→finish) for signing up to 8KB
- **Flood scope**: Transport key filtering for region-scoped sends
### 6.2.1 V-Contact (Loopback Admin Contact)
ZephCore-only feature (no Arduino equivalent). The companion synthesizes a CHAT
contact named `v<node_name>` that exists only toward the connected BLE/USB app.
Chatting with it runs the same text CLI as the USB serial sideband; the reply
comes back as normal chat messages. The firmware also uses it to emit
unsolicited notices: a one-shot low-battery alert and a restart-reason message
(SOFTWARE/WATCHDOG/LOCKUP/BROWNOUT causes only — plain power-on is log-only).
**Identity**: pubkey = `SHA256("zc-vcontact" || self_pubkey)` — stable per
node, unique per device, and deliberately **not a real keypair**: no private
key exists anywhere.
**No-RF invariants** (all enforced in `CompanionMesh`):
1. `vcontactHandleFrame()` intercepts `CMD_SEND_TXT_MSG` (and the handful of
other opcodes that must succeed) *before* any contact lookup — the CLI runs
and the reply is written straight into the offline queue. **No packet
object is ever created**, so nothing can reach the dispatcher or radio.
2. The v-contact never enters the real contacts table (`CMD_ADD_UPDATE_CONTACT`
for its key is intercepted to a no-op OK), so it is never in the RF RX
matching path. Every other pubkey-addressed opcode (login, telemetry,
binary req, path discovery…) misses `lookupContactByPubKey()` and fails
`ERR_NOT_FOUND` before a packet exists.
3. Even a hand-crafted over-the-air packet addressed to the derived pubkey is
inert: unknown dest, undecryptable by everyone including this node.
**App plumbing**: appears as a virtual tail entry in the `CMD_GET_CONTACTS`
iteration (and `+1` in the CONTACT_START total); pushed as `NEW_ADVERT` on
runtime enable and rename, `CONTACT_DELETED` on disable. App-side contact
delete (`CMD_REMOVE_CONTACT`) turns the feature off. Send/ack choreography is
synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are
chunked at ≤150 chars on line breaks (offline-queue frames cap at 172 bytes).
**Notices ride the offline queue** — emitted while nothing is connected, they
are delivered on the first app connect/sync. RAM-backed: lost on reboot (the
restart-reason message partially compensates) and bounded by
`CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`.
**Settings** (companion `v.*` CLI namespace, prefs offsets 152–154):
- `set/get v.contact on|off` — default on.
- `set/get v.batteryalert <mV>|0|default` — default = board auto-shutdown
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.
### 6.3 RepeaterMesh
Autonomous operation features:
@@ -674,6 +674,25 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs)
prefs.meshtimesync = 0;
}
}
/* Offset 152: v_contact_enabled (ZephCore extension, default 1 = on) */
if (off < len) {
prefs.v_contact_enabled = buf[off++];
if (prefs.v_contact_enabled > 1) {
prefs.v_contact_enabled = 1;
}
} else {
prefs.v_contact_enabled = 1;
}
/* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE).
* 0xFFFF sentinel = derive from board auto-shutdown threshold; 0 = off. */
if (off + 2 <= len) {
prefs.v_battery_alert_mv = (uint16_t)buf[off] | ((uint16_t)buf[off + 1] << 8);
off += 2;
} else {
prefs.v_battery_alert_mv = 0xFFFF;
}
}
void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
@@ -750,7 +769,12 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
buf[off++] = prefs.rx_duty_cycle;
/* Offset 151: meshtimesync (ZephCore extension) */
buf[off++] = prefs.meshtimesync;
/* Total: 152 bytes */
/* Offset 152: v_contact_enabled (ZephCore extension) */
buf[off++] = prefs.v_contact_enabled;
/* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE) */
buf[off++] = prefs.v_battery_alert_mv & 0xFF;
buf[off++] = (prefs.v_battery_alert_mv >> 8) & 0xFF;
/* Total: 155 bytes */
bool ok = atomicReplaceFile(PREFS_FILE, buf, off);
LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'",
+227
View File
@@ -209,6 +209,9 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes
_dirty_channels_expiry = 0;
memset(_send_scope.key, 0, sizeof(_send_scope.key));
_send_scope_force_unscoped = false;
_vcontact_cli_cb = nullptr;
memset(_vcontact_pubkey, 0, sizeof(_vcontact_pubkey));
_vcontact_lastmod = 0;
memset(&prefs, 0, sizeof(prefs));
prefs.node_lat = 0;
prefs.node_lon = 0;
@@ -217,6 +220,15 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes
void CompanionMesh::begin()
{
BaseChatMesh::begin();
/* Derive the v-contact pubkey from our identity: stable per node, unique
* per device. Deliberately NOT a real keypair — no private key exists
* anywhere, so nothing addressed to this key is decryptable by anyone. */
static const char vc_salt[] = "zc-vcontact";
mesh::Utils::sha256(_vcontact_pubkey, PUB_KEY_SIZE,
(const uint8_t *)vc_salt, sizeof(vc_salt) - 1,
self_id.pub_key, PUB_KEY_SIZE);
_vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime();
#ifdef CONFIG_ZEPHCORE_APC
_power_ctrl.setSF(prefs.sf);
_power_ctrl.setTargetMargin(prefs.apc_margin);
@@ -603,6 +615,22 @@ bool CompanionMesh::continueContactIteration()
}
_contact_iter_idx++;
return true;
} else if (_contact_iter_idx == getNumContacts()) {
// Virtual tail entry: the v-contact (never in the real table)
if (isVContactEnabled() && _vcontact_lastmod > _contact_iter_since) {
if (_vcontact_lastmod > _contact_iter_lastmod) {
_contact_iter_lastmod = _vcontact_lastmod;
}
ContactInfo vc;
buildVContact(vc);
uint8_t rsp[CONTACT_FRAME_SIZE];
size_t n = serializeContact(rsp, vc, PACKET_CONTACT);
if (!writeFrame(rsp, n)) {
return true; /* retry this step when TX drains */
}
}
_contact_iter_idx++;
return true;
} else {
// Send PACKET_CONTACT_END with most_recent_lastmod
uint8_t rsp[5];
@@ -816,6 +844,196 @@ void CompanionMesh::queueContactMessage(const ContactInfo &contact, mesh::Packet
queueOfflineMessage(frame, i);
}
/* ========== V-contact: loopback admin contact ==========
* A synthesized CHAT contact ("v<node_name>") that exists only toward the
* connected BLE/USB app. Chat messages to it run the text CLI; replies and
* unsolicited notices (battery alert, restart reason) come back as normal
* contact messages via the offline queue. Invariants:
* - never enters the real contacts table (and thus never the RF RX
* matching path) — CMD_ADD_UPDATE_CONTACT is intercepted to a no-op OK;
* - no packet object is ever created for it — interception happens before
* any sendMessage/sendRequest path, so nothing can reach the radio;
* - its pubkey is SHA256-derived with no private key, so even hand-crafted
* over-the-air traffic addressed to it is undecryptable and dropped. */
void CompanionMesh::buildVContact(ContactInfo &c) const
{
memcpy(c.id.pub_key, _vcontact_pubkey, PUB_KEY_SIZE);
c.type = ADV_TYPE_CHAT;
c.flags = 0;
c.out_path_len = 0; /* zero-hop direct — renders as "0 hops" in the app */
c.shared_secret_valid = false;
memset(c.out_path, 0, sizeof(c.out_path));
c.name[0] = 'v';
StrHelper::strzcpy(&c.name[1], prefs.node_name, sizeof(c.name) - 1);
c.last_advert_timestamp = _vcontact_lastmod;
c.lastmod = _vcontact_lastmod;
c.gps_lat = 0;
c.gps_lon = 0;
c.sync_since = 0;
}
bool CompanionMesh::isVContactKey(const uint8_t *key, int prefix_len) const
{
if (!isVContactEnabled()) return false;
if (prefix_len > PUB_KEY_SIZE) prefix_len = PUB_KEY_SIZE;
return memcmp(key, _vcontact_pubkey, prefix_len) == 0;
}
void CompanionMesh::vcontactQueueText(const char *text)
{
/* Offline-queue frames cap at 172 bytes and the V3 header takes 16, so
* split long CLI replies into <=150-char chunks, preferring line breaks.
* Each chunk becomes its own chat message; getCurrentTimeUnique() keeps
* their timestamps strictly increasing so the app orders them. */
static const size_t CHUNK_MAX = 150;
ContactInfo vc;
buildVContact(vc);
const char *p = text;
size_t remaining = strlen(text);
while (remaining > 0) {
size_t take = remaining;
if (take > CHUNK_MAX) {
take = CHUNK_MAX;
for (size_t i = take; i > CHUNK_MAX / 2; i--) {
if (p[i - 1] == '\n') { take = i; break; }
}
}
char chunk[CHUNK_MAX + 1];
memcpy(chunk, p, take);
chunk[take] = '\0';
size_t adv = take;
for (size_t i = 0; i < take; i++) {
if (chunk[i] == '\r') chunk[i] = ' '; /* CRLF CLI output → LF */
}
while (take > 0 && (chunk[take - 1] == '\n' || chunk[take - 1] == ' ')) {
chunk[--take] = '\0'; /* trim trailing break of this bubble */
}
if (take > 0) {
queueContactMessage(vc, nullptr, TXT_TYPE_PLAIN,
getRTCClock()->getCurrentTimeUnique(), nullptr, 0, chunk);
}
p += adv;
remaining -= adv;
}
sendPush(PUSH_CODE_MSG_WAITING);
}
void CompanionMesh::vcontactNotify(const char *text)
{
if (!isVContactEnabled() || !text || !text[0]) return;
LOG_INF("vcontact notify: %s", text);
vcontactQueueText(text);
}
void CompanionMesh::vcontactPushAdvert()
{
if (!isVContactEnabled()) return;
/* Bump lastmod so incremental contact syncs (since > 0) pick up the
* rename/re-enable. */
_vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime();
ContactInfo vc;
buildVContact(vc);
uint8_t rsp[CONTACT_FRAME_SIZE];
size_t n = serializeContact(rsp, vc); /* no header — push code is separate */
sendPush(PUSH_CODE_NEW_ADVERT, rsp, n);
}
void CompanionMesh::vcontactPushDeleted()
{
sendPush(PUSH_CODE_CONTACT_DELETED, _vcontact_pubkey, PUB_KEY_SIZE);
}
bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len)
{
if (!isVContactEnabled()) return false;
switch (data[0]) {
case CMD_SEND_TXT_MSG:
/* Same frame layout as the real handler: cmd(1) + txt_type(1) +
* attempt(1) + timestamp(4) + pub_key_prefix(6) + text(N). */
if (len >= 14 && isVContactKey(&data[7], 6)) {
uint8_t txt_type = data[1];
if (txt_type != TXT_TYPE_PLAIN && txt_type != TXT_TYPE_CLI_DATA) {
sendPacketError(ERR_UNSUPPORTED);
return true;
}
char line[MAX_TEXT_LEN + 1];
size_t text_len = len - 13;
if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN;
memcpy(line, &data[13], text_len);
line[text_len] = '\0';
LOG_INF("vcontact CLI: '%s'", line);
char reply[VCONTACT_CLI_REPLY_SIZE];
reply[0] = '\0';
if (_vcontact_cli_cb) {
_vcontact_cli_cb(line, reply);
} else {
strcpy(reply, "CLI not available");
}
/* Synthesize the normal send/ack choreography: SENT response,
* then an immediate delivery confirmation (loopback, 0 ms). */
uint32_t ack = 0;
getRNG()->random((uint8_t *)&ack, 4);
if (ack == 0) ack = 1;
sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 100);
uint8_t ack_push[8];
memcpy(ack_push, &ack, 4);
memset(&ack_push[4], 0, 4); /* trip time: 0 ms */
sendPush(PUSH_CODE_SEND_CONFIRMED, ack_push, 8);
if (reply[0] != '\0') {
vcontactQueueText(reply);
}
return true;
}
return false;
case CMD_GET_CONTACT_BY_KEY:
if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) {
ContactInfo vc;
buildVContact(vc);
uint8_t rsp[CONTACT_FRAME_SIZE];
size_t n = serializeContact(rsp, vc, PACKET_CONTACT);
writeFrame(rsp, n);
return true;
}
return false;
case CMD_ADD_UPDATE_CONTACT:
case CMD_RESET_PATH:
/* Never let the v-contact into the real contacts table (it must stay
* out of the RF RX matching path); path resets are meaningless for a
* loopback contact. Reply OK so app-side flows don't surface errors. */
if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) {
sendPacketOk();
return true;
}
return false;
case CMD_REMOVE_CONTACT:
/* App-side delete turns the feature off (mirrors user intent);
* `set v.contact on` (USB CLI) brings it back. */
if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) {
prefs.v_contact_enabled = 0;
_store->savePrefs(prefs);
sendPacketOk();
return true;
}
return false;
default:
/* Every other pubkey-addressed opcode (login, telemetry, binary req,
* path discovery, export, ...) resolves the contact via
* lookupContactByPubKey(); the v-contact is never in the table, so
* they fail with ERR_NOT_FOUND before any packet exists. */
return false;
}
}
void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact,
uint32_t timestamp, const char *text, bool delivered)
{
@@ -1648,6 +1866,12 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len)
resetContactIterator();
}
/* V-contact interception — must run before any contact lookup so a frame
* addressed to the loopback contact can never create a radio packet. */
if (vcontactHandleFrame(data, len)) {
return true;
}
switch (data[0]) {
case CMD_APP_START: {
if (len < 8) {
@@ -1712,6 +1936,7 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len)
ContactInfo c;
if (getContactByIdx(i, c) && c.type != ADV_TYPE_NONE) total++;
}
if (isVContactEnabled()) total++; /* virtual tail entry */
uint8_t rsp[5];
rsp[0] = PACKET_CONTACT_START;
put_le32(&rsp[1], total);
@@ -2149,6 +2374,8 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len)
_store->savePrefs(prefs);
/* Push the new name to BLE so scanners see it without a reboot. */
zephcore_ble_update_name(prefs.node_name);
/* v-contact name tracks the node name — update the app's copy. */
vcontactPushAdvert();
}
sendPacketOk();
return true;
+36
View File
@@ -91,6 +91,12 @@ typedef void (*RadioReconfigureCallback)(void);
/* BLE PIN change callback */
typedef void (*PinChangeCallback)(uint32_t new_pin);
/* V-contact CLI execution callback — runs a text-CLI line and fills `reply`
* (buffer is VCONTACT_CLI_REPLY_SIZE). Registered by main_companion so the
* v-contact chat reuses the same CommonCLI instance as the USB text CLI. */
#define VCONTACT_CLI_REPLY_SIZE 256
typedef void (*VContactCLICallback)(const char *line, char *reply);
/**
* CompanionMesh: Application layer for ZephCore Companion device
*
@@ -154,6 +160,24 @@ public:
*/
void setPinChangeCallback(PinChangeCallback cb) { _pin_change_cb = cb; }
/* ---- V-contact: loopback admin contact ("v<node_name>") ----
* A synthesized CHAT contact visible only to the connected BLE/USB app.
* Messages to it are short-circuited into the CLI before any packet is
* created — nothing ever reaches the dispatcher or the radio. Its pubkey
* is SHA256("zc-vcontact" || self pubkey); no private key exists and it
* is never registered in the RF RX matching path, so over-the-air
* traffic addressed to it is inert. */
void setVContactCLICallback(VContactCLICallback cb) { _vcontact_cli_cb = cb; }
bool isVContactEnabled() const { return prefs.v_contact_enabled != 0; }
/** Queue an unsolicited v-contact message (battery alert, restart reason).
* Goes through the offline queue — delivered on next app connect/sync. */
void vcontactNotify(const char *text);
/** Push the v-contact to a connected app as a NEW_ADVERT (call after
* runtime enable or rename). No-op push when nothing is connected. */
void vcontactPushAdvert();
/** Push CONTACT_DELETED for the v-contact (call after runtime disable). */
void vcontactPushDeleted();
#ifdef CONFIG_ZEPHCORE_APC
/* Adaptive Power Control hooks used by the USB text CLI. */
int8_t getAPCReduction() const {
@@ -466,6 +490,18 @@ private:
void queueContactMessage(const ContactInfo &contact, mesh::Packet *pkt,
uint8_t txt_type, uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text);
/* V-contact internals */
VContactCLICallback _vcontact_cli_cb;
uint8_t _vcontact_pubkey[PUB_KEY_SIZE];
uint32_t _vcontact_lastmod;
void buildVContact(ContactInfo &c) const;
bool isVContactKey(const uint8_t *key, int prefix_len) const;
/** Intercept protocol frames addressed to the v-contact. Returns true when
* the frame was fully handled (response already written). */
bool vcontactHandleFrame(const uint8_t *data, size_t len);
/** Chunk `text` into offline-queue contact messages from the v-contact. */
void vcontactQueueText(const char *text);
void addPendingAck(uint32_t expected, int contact_idx);
int findAndRemoveAck(uint32_t ack, uint32_t *out_sent_time = nullptr);
+4
View File
@@ -86,6 +86,8 @@ 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
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
};
/* Default prefs -- must match LoRaConfig.h defaults for radio interop. */
@@ -135,4 +137,6 @@ static inline void initNodePrefs(NodePrefs* prefs) {
prefs->apc_enabled = 0; // Default OFF — fixed TX power
prefs->apc_margin = 16; // Default 16 dB target link margin
prefs->wake_on_msg = 1; // Default ON — wake display when message arrives
prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion)
prefs->v_battery_alert_mv = 0xFFFF; // Sentinel: derive from board auto-shutdown threshold
}
+241 -29
View File
@@ -97,9 +97,10 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
#define MESH_EVENT_CONTACT_ITER BIT(10) /* Continue contact-dump iteration on main thread */
#ifdef ZEPHCORE_LORA
/* Forward decl — data_store + companion_mesh_ptr statics are defined further
/* Forward decls — data_store + companion_mesh_ptr statics are defined further
* down in the file, so mesh_event_loop() can't reference them directly. */
static void save_prefs_to_flash(void);
static void vcontact_battery_alert_check(void);
#endif
/* Pending epoch for a deferred zephcore_rtc_save(). gps_fix_callback runs on
@@ -535,6 +536,11 @@ static void mesh_event_loop(void)
* and compiled out unless the board sets a threshold — no
* extra poll, just a cheap call on the existing tick. */
ui_auto_shutdown_check();
/* V-contact low-battery alert — independent of the UI-driven
* auto-shutdown above (which is a weak no-op on headless
* builds), same 30 s self-throttle. */
vcontact_battery_alert_check();
}
/* Off-main pref mutators (e.g. the autoshutdown CLI handler on
@@ -661,11 +667,12 @@ static void save_prefs_to_flash(void)
data_store.savePrefs(companion_mesh_ptr->prefs);
}
/* ========== Companion CLI (USB text sideband only) ==========
* Text CLI lives on USB CDC only: its '<' sync byte cleanly separates binary
* V3 frames from text, whereas BLE NUS frames have no prefix and V3 opcodes
* overlap printable ASCII, so a BLE text sideband can't be disambiguated. */
#if ZEPHCORE_USB_STACK
/* ========== Companion text CLI ==========
* One CommonCLI instance shared by two front-ends: the USB CDC text sideband
* (its '<' sync byte separates binary V3 frames from text; BLE NUS has no
* such prefix, so a direct BLE text sideband can't be disambiguated) and the
* v-contact chat, which carries CLI lines inside the binary protocol on
* either transport — so this block is NOT gated on ZEPHCORE_USB_STACK. */
class CompanionCLICallbacks : public CommonCLICallbacks {
public:
@@ -785,26 +792,20 @@ static ClientACL companion_acl; /* unused by CommonCLI but required by construc
static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl,
&companion_mesh.prefs, &companion_cli_cbs);
/* Dispatch a CLI text line from USB, reply back over USB CDC. Output mirrors
* the repeater's serial CLI ("\r\n -> <reply>\r\n", CRLF) so the
* flasher.meshcore.io serial console renders companion replies identically. */
#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && \
CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0
#define ZEPHCORE_HAS_AUTO_SHUTDOWN 1
#else
#define ZEPHCORE_HAS_AUTO_SHUTDOWN 0
#endif
#if ZEPHCORE_HAS_AUTO_SHUTDOWN
/* Companion-only CLI sideband for the low-battery auto-shutdown threshold.
* Handled here (not in shared CommonCLI) so the pref persists via the
* companion datastore and the command never appears on repeater builds.
* Returns true if the line was a recognised autoshutdown command. */
static bool handle_autoshutdown_cli(const char *line, char *reply)
{
if (strcmp(line, "help") == 0 || strcmp(line, "?") == 0) {
/* No global CLI help exists; list only the companion-specific extras
* and make clear the standard set/get radio+mesh commands also work. */
strcpy(reply,
"Auto-shutdown (plus the standard set/get commands):\r\n"
" get autoshutdown - show low-battery cutoff\r\n"
" set autoshutdown <mV> - 0 = off, or 1-5000 mV");
return true;
}
if (strcmp(line, "get autoshutdown") == 0) {
uint16_t mv = companion_mesh.prefs.auto_shutdown_mv;
if (mv == 0) {
@@ -850,25 +851,210 @@ static bool handle_autoshutdown_cli(const char *line, char *reply)
}
return false;
}
#endif
#endif /* ZEPHCORE_HAS_AUTO_SHUTDOWN */
/* Executes a text-CLI line — runs on the MAIN thread (drained from
* companion_cli_queue in the event loop). CommonCLI::handleCommand touches
* mesh state shared with loop(), so it must not run on sysworkq. */
static void companion_cli_run(const char *line)
/* V-contact battery alert state — defined below with the checker. */
static void vcontact_battery_alert_rearm(void);
/* Resolve the effective v-contact battery-alert threshold in mV.
* 0 = alert off; the 0xFFFF sentinel derives the board default: 200 mV above
* the auto-shutdown cutoff so the alert wins the race against the shutdown
* confirm window (90 s), falling back to 3500 mV on boards without one. */
static uint16_t vcontact_battery_alert_threshold_mv(void)
{
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;
}
/* Companion-only `v.*` commands (v-contact settings) + the shared help text.
* Runs on the main thread for both front-ends (USB lines are drained from
* companion_cli_queue in the event loop; v-contact lines arrive via
* handleProtocolFrame). PREFS_DIRTY keeps the flash write on the main loop
* regardless. Returns true if the line was recognised. */
static bool handle_vcontact_cli(const char *line, char *reply)
{
if (strcmp(line, "help") == 0 || strcmp(line, "?") == 0) {
/* No global CLI help exists; list only the companion-specific extras
* and make clear the standard set/get radio+mesh commands also work. */
strcpy(reply,
"Companion extras (standard set/get commands also work):\r\n"
#if ZEPHCORE_HAS_AUTO_SHUTDOWN
" get|set autoshutdown <mV> - low-batt cutoff, 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");
return true;
}
if (strcmp(line, "get v.contact") == 0) {
snprintf(reply, CLI_REPLY_SIZE, "v.contact: %s",
companion_mesh.prefs.v_contact_enabled ? "on" : "off");
return true;
}
if (strncmp(line, "set v.contact ", 14) == 0) {
const char *arg = line + 14;
while (*arg == ' ') arg++;
bool en;
if (strcmp(arg, "on") == 0 || strcmp(arg, "1") == 0) {
en = true;
} else if (strcmp(arg, "off") == 0 || strcmp(arg, "0") == 0) {
en = false;
} else {
strcpy(reply, "ERROR: use on|off|1|0");
return true;
}
bool was = companion_mesh.prefs.v_contact_enabled != 0;
companion_mesh.prefs.v_contact_enabled = en ? 1 : 0;
k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY);
/* Tell a connected app right away — add or remove the contact. */
if (en && !was) {
companion_mesh.vcontactPushAdvert();
} else if (!en && was) {
companion_mesh.vcontactPushDeleted();
}
snprintf(reply, CLI_REPLY_SIZE, "OK - v.contact %s", en ? "on" : "off");
return true;
}
if (strcmp(line, "get v.batteryalert") == 0) {
uint16_t pref = companion_mesh.prefs.v_battery_alert_mv;
if (pref == 0) {
strcpy(reply, "v.batteryalert: off");
} else if (pref == 0xFFFF) {
snprintf(reply, CLI_REPLY_SIZE, "v.batteryalert: default (%u mV)",
vcontact_battery_alert_threshold_mv());
} else {
snprintf(reply, CLI_REPLY_SIZE, "v.batteryalert: %u mV", pref);
}
return true;
}
if (strncmp(line, "set v.batteryalert ", 19) == 0) {
const char *arg = line + 19;
while (*arg == ' ') arg++;
uint16_t v;
if (strncmp(arg, "default", 7) == 0) {
v = 0xFFFF;
} else {
/* Numbers only, same validation as autoshutdown. */
char *end = NULL;
long n = strtol(arg, &end, 10);
while (*end == ' ' || *end == '\r' || *end == '\n' || *end == '\t') {
end++;
}
if (arg[0] < '0' || arg[0] > '9' || *end != '\0' || n > 5000) {
strcpy(reply, "ERROR: use 0 (off), 1-5000 mV, or default");
return true;
}
v = (uint16_t)n;
}
companion_mesh.prefs.v_battery_alert_mv = v;
k_event_post(&mesh_events, MESH_EVENT_PREFS_DIRTY);
vcontact_battery_alert_rearm();
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());
} else {
snprintf(reply, CLI_REPLY_SIZE, "OK - v.batteryalert %u mV", v);
}
return true;
}
return false;
}
/* Transport-neutral CLI line execution — runs on the MAIN thread only
* (CommonCLI::handleCommand touches mesh state shared with loop()). Both the
* USB text sideband and the v-contact chat funnel through here. `reply` must
* be CLI_REPLY_SIZE (== VCONTACT_CLI_REPLY_SIZE). */
static_assert(VCONTACT_CLI_REPLY_SIZE == CLI_REPLY_SIZE,
"v-contact reply buffer must match CommonCLI reply size");
static void companion_cli_exec(const char *line, char *reply)
{
char reply[CLI_REPLY_SIZE];
reply[0] = '\0';
#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && \
CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0
if (handle_vcontact_cli(line, reply)) {
return;
}
#if ZEPHCORE_HAS_AUTO_SHUTDOWN
if (handle_autoshutdown_cli(line, reply)) {
zephcore_usb_companion_write_text("\r\n -> ", 7);
zephcore_usb_companion_write_text(reply, strlen(reply));
zephcore_usb_companion_write_text("\r\n", 2);
return;
}
#endif
companion_cli.handleCommand(0, line, reply);
}
/* ========== V-contact battery alert ==========
* Deliberately NOT hooked into ui_auto_shutdown_check() — that is a weak
* no-op on headless builds. Same proven pattern though: 30 s ADC sample gate
* off the 5 s housekeeping tick, 3-strike confirm so a TX-induced sag can't
* false-trigger, skip while externally powered. One-shot latch per discharge
* cycle; re-arms on charge/recovery (threshold + 150 mV) or threshold change.
* The message rides the offline queue, so it still reaches the app if nothing
* is connected when it fires. */
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
/* Executes a text-CLI line from USB, reply back over USB CDC. Output mirrors
* the repeater's serial CLI ("\r\n -> <reply>\r\n", CRLF) so the
* flasher.meshcore.io serial console renders companion replies identically.
* Runs on the MAIN thread (drained from companion_cli_queue in the event
* loop). */
static void companion_cli_run(const char *line)
{
char reply[CLI_REPLY_SIZE];
companion_cli_exec(line, reply);
if (reply[0] != '\0') {
zephcore_usb_companion_write_text("\r\n -> ", 7);
zephcore_usb_companion_write_text(reply, strlen(reply));
@@ -1001,6 +1187,8 @@ int main(void)
LOG_INF("=== ZephCore starting ===");
/* Log reset reason so we can diagnose random reboots */
char boot_cause_msg[64];
boot_cause_msg[0] = '\0';
{
uint32_t cause;
if (hwinfo_get_reset_cause(&cause) == 0) {
@@ -1012,6 +1200,20 @@ int main(void)
(cause & RESET_WATCHDOG) ? " WATCHDOG" : "",
(cause & RESET_CPU_LOCKUP)? " LOCKUP" : "");
hwinfo_clear_reset_cause();
/* Informative causes become a v-contact message once the
* mesh is up (queued below, after RTC restore + prefs load).
* Plain POR/PIN — the user just powered the device on — is
* noise and stays log-only. A deliberate CLI/app reboot shows
* as SOFTWARE, doubling as a "reboot completed" confirmation. */
if (cause & (RESET_SOFTWARE | RESET_BROWNOUT |
RESET_WATCHDOG | RESET_CPU_LOCKUP)) {
snprintf(boot_cause_msg, sizeof(boot_cause_msg),
"Restarted:%s%s%s%s",
(cause & RESET_SOFTWARE) ? " SOFTWARE" : "",
(cause & RESET_BROWNOUT) ? " BROWNOUT" : "",
(cause & RESET_WATCHDOG) ? " WATCHDOG" : "",
(cause & RESET_CPU_LOCKUP) ? " LOCKUP" : "");
}
}
}
@@ -1169,6 +1371,8 @@ int main(void)
companion_mesh.setPinChangeCallback([](uint32_t new_pin) {
zephcore_ble_set_passkey(new_pin);
});
/* V-contact chat lines run the same CLI as the USB text sideband. */
companion_mesh.setVContactCLICallback(companion_cli_exec);
companion_mesh_ptr = &companion_mesh;
/* Set LoRa callbacks for event-driven packet processing */
@@ -1185,6 +1389,14 @@ int main(void)
* CMD_SET_RADIO_PARAMS changes are visible to lora_radio.reconfigure(). */
lora_radio.setPrefs(&companion_mesh.prefs);
/* Queue the restart-reason notice from the v-contact. Deferred to here so
* begin() has derived the v-contact key, prefs (enable flag) are loaded,
* and the hardware-RTC restore above gives the message a sane timestamp.
* It sits in the offline queue until the first app connect/sync. */
if (boot_cause_msg[0] != '\0') {
companion_mesh.vcontactNotify(boot_cause_msg);
}
/* Push initial state to UI display */
ui_set_node_name(companion_mesh.prefs.node_name);
ui_set_radio_params(