mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-10-23 04:41:32 +00:00
vcontact p1
This commit is contained in:
1 parent
748e7917e2
commit
0e7a9b7285
6 files changed
+581
-30
No files matched your search
@@ -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'",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user