diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 220dea0..0a36cb5 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -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` 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 |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: diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index fc31ee6..3ec1f1a 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -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'", diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index be6f6e4..4aa4ed9 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -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") 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; diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 9d591ba..e60fe1d 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -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") ---- + * 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); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 9a15a17..8e9993a 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -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 } diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 580b69a..bb67313 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -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 -> \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 - 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 - low-batt cutoff, 0 = off\r\n" +#endif + " get|set v.contact on|off - loopback admin contact\r\n" + " get|set v.batteryalert - 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 -> \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(