feat: happiness scoring pipeline + ESP32 swarm with Cognitum Seed (#285)

* feat: happiness scoring pipeline with ESP32 swarm + Cognitum Seed coordinator

ADR-065: Hotel guest happiness scoring from WiFi CSI physiological proxies.
ADR-066: ESP32 swarm with Cognitum Seed as coordinator for multi-zone analytics.

Firmware:
- swarm_bridge.c/h: FreeRTOS task on Core 0, HTTP client with Bearer auth,
  registers with Seed, sends heartbeats (30s) and happiness vectors (5s)
- nvs_config: seed_url, seed_token, zone_name, swarm intervals
- provision.py: --seed-url, --seed-token, --zone CLI args
- esp32-hello-world: capability discovery firmware for 4MB ESP32-S3 variant

WASM edge modules:
- exo_happiness_score.rs: 8-dim happiness vector from gait speed, stride
  regularity, movement fluidity, breathing calm, posture, dwell time
  (events 690-694, 11 tests, ESP32-optimized buffers + event decimation)
- ghost_hunter.rs standalone binary: 5.7 KB WASM, feature-gated default pipeline

RuView Live:
- --mode happiness dashboard with bar visualization
- --seed flag for Cognitum Seed bridge (urllib, background POST)
- HappinessScorer + SeedBridge classes (stdlib only, no deps)

Examples:
- seed_query.py: CLI tool (status, search, witness, monitor, report)
- provision_swarm.sh: batch provisioning for multi-node deployment
- happiness_vector_schema.json: 8-dim vector format documentation

Verified live: ESP32 on COM5 (4MB flash) registered with Seed at 10.1.10.236,
vectors flowing, witness chain growing (epoch 455, chain 1108).

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: raise firmware binary size gate to 1100 KB for HTTP client stack

The swarm bridge (ADR-066) adds esp_http_client for Seed communication,
which pulls in the HTTP/TLS stack (~150 KB). Binary grew from ~978 KB to
~1077 KB. Raise the gate from 950 KB to 1100 KB. Still fits comfortably
in both 4MB (1856 KB OTA slot, 43% free) and 8MB flash variants.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-03-20 18:46:34 -04:00
committed by GitHub
parent 8a84748a83
commit 2b8a7cc458
22 changed files with 3071 additions and 39 deletions
@@ -3,6 +3,7 @@ set(SRCS
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
"swarm_bridge.c"
)
set(REQUIRES "")
+27 -2
View File
@@ -28,6 +28,7 @@
#include "wasm_upload.h"
#include "display_task.h"
#include "mmwave_sensor.h"
#include "swarm_bridge.h"
#ifdef CONFIG_CSI_MOCK_ENABLED
#include "mock_csi.h"
#endif
@@ -240,6 +241,29 @@ void app_main(void)
ESP_LOGI(TAG, "No mmWave sensor detected (CSI-only mode)");
}
/* ADR-066: Initialize swarm bridge to Cognitum Seed (if configured). */
esp_err_t swarm_ret = ESP_ERR_INVALID_ARG;
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
if (g_nvs_config.seed_url[0] != '\0') {
swarm_config_t swarm_cfg = {
.heartbeat_sec = g_nvs_config.swarm_heartbeat_sec,
.ingest_sec = g_nvs_config.swarm_ingest_sec,
.enabled = 1,
};
strncpy(swarm_cfg.seed_url, g_nvs_config.seed_url, sizeof(swarm_cfg.seed_url) - 1);
strncpy(swarm_cfg.seed_token, g_nvs_config.seed_token, sizeof(swarm_cfg.seed_token) - 1);
strncpy(swarm_cfg.zone_name, g_nvs_config.zone_name, sizeof(swarm_cfg.zone_name) - 1);
swarm_ret = swarm_bridge_init(&swarm_cfg, g_nvs_config.node_id);
if (swarm_ret != ESP_OK) {
ESP_LOGW(TAG, "Swarm bridge init failed: %s", esp_err_to_name(swarm_ret));
}
} else {
ESP_LOGI(TAG, "Swarm bridge disabled (no seed_url configured)");
}
#else
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
#endif
/* Initialize power management. */
power_mgmt_init(g_nvs_config.power_duty);
@@ -251,12 +275,13 @@ void app_main(void)
}
#endif
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s)",
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)",
g_nvs_config.target_ip, g_nvs_config.target_port,
g_nvs_config.edge_tier,
(ota_ret == ESP_OK) ? "ready" : "off",
(wasm_ret == ESP_OK) ? "ready" : "off",
(mmwave_ret == ESP_OK) ? "active" : "off");
(mmwave_ret == ESP_OK) ? "active" : "off",
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off");
/* Main loop — keep alive */
while (1) {
+20
View File
@@ -302,6 +302,26 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
}
/* ADR-066: Swarm bridge */
len = sizeof(cfg->seed_url);
if (nvs_get_str(handle, "seed_url", cfg->seed_url, &len) != ESP_OK) {
cfg->seed_url[0] = '\0'; /* Disabled by default */
}
len = sizeof(cfg->seed_token);
if (nvs_get_str(handle, "seed_token", cfg->seed_token, &len) != ESP_OK) {
cfg->seed_token[0] = '\0';
}
len = sizeof(cfg->zone_name);
if (nvs_get_str(handle, "zone_name", cfg->zone_name, &len) != ESP_OK) {
strncpy(cfg->zone_name, "default", sizeof(cfg->zone_name) - 1);
}
if (nvs_get_u16(handle, "swarm_hb", &cfg->swarm_heartbeat_sec) != ESP_OK) {
cfg->swarm_heartbeat_sec = 30;
}
if (nvs_get_u16(handle, "swarm_ingest", &cfg->swarm_ingest_sec) != ESP_OK) {
cfg->swarm_ingest_sec = 5;
}
/* Validate tdm_slot_index < tdm_node_count */
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",
@@ -55,6 +55,13 @@ typedef struct {
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
/* ADR-066: Swarm bridge configuration */
char seed_url[64]; /**< Cognitum Seed base URL (empty = disabled). */
char seed_token[64]; /**< Seed Bearer token (from pairing). */
char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */
uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */
uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */
} nvs_config_t;
/**
+327
View File
@@ -0,0 +1,327 @@
/**
* @file swarm_bridge.c
* @brief ADR-066: ESP32 Swarm Bridge — Cognitum Seed coordinator client.
*
* Runs a FreeRTOS task on Core 0 that periodically POSTs registration,
* heartbeat, and happiness vectors to a Cognitum Seed ingest endpoint.
*/
#include "swarm_bridge.h"
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_app_desc.h"
#include "esp_netif.h"
#include "esp_http_client.h"
static const char *TAG = "swarm";
/* ---- Task parameters ---- */
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
#define SWARM_TASK_PRIO 3
#define SWARM_TASK_CORE 0
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
/* ---- Ingest endpoint path ---- */
#define SWARM_INGEST_PATH "/api/v1/store/ingest"
/* ---- JSON buffer size (Seed tuple format: max ~120 bytes per vector) ---- */
#define SWARM_JSON_BUF 256
/* ---- Module state ---- */
static swarm_config_t s_cfg;
static uint8_t s_node_id;
static SemaphoreHandle_t s_mutex;
static TaskHandle_t s_task_handle;
/* ---- Protected shared data ---- */
static edge_vitals_pkt_t s_vitals;
static float s_happiness[SWARM_VECTOR_DIM];
static bool s_vitals_valid;
/* ---- Counters ---- */
static uint32_t s_cnt_regs;
static uint32_t s_cnt_heartbeats;
static uint32_t s_cnt_ingests;
static uint32_t s_cnt_errors;
/* ---- Forward declarations ---- */
static void swarm_task(void *arg);
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
const char *json, int json_len);
static void swarm_get_ip_str(char *buf, size_t buf_len);
/* ------------------------------------------------------------------ */
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
{
if (cfg == NULL || cfg->seed_url[0] == '\0') {
ESP_LOGW(TAG, "seed_url is empty — swarm bridge disabled");
return ESP_ERR_INVALID_ARG;
}
memcpy(&s_cfg, cfg, sizeof(s_cfg));
s_node_id = node_id;
/* Apply defaults for zero-valued intervals. */
if (s_cfg.heartbeat_sec == 0) {
s_cfg.heartbeat_sec = 30;
}
if (s_cfg.ingest_sec == 0) {
s_cfg.ingest_sec = 5;
}
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == NULL) {
ESP_LOGE(TAG, "failed to create mutex");
return ESP_ERR_NO_MEM;
}
s_vitals_valid = false;
memset(s_happiness, 0, sizeof(s_happiness));
s_cnt_regs = 0;
s_cnt_heartbeats = 0;
s_cnt_ingests = 0;
s_cnt_errors = 0;
BaseType_t ret = xTaskCreatePinnedToCore(
swarm_task, "swarm", SWARM_TASK_STACK, NULL,
SWARM_TASK_PRIO, &s_task_handle, SWARM_TASK_CORE);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create swarm task");
vSemaphoreDelete(s_mutex);
s_mutex = NULL;
return ESP_FAIL;
}
ESP_LOGI(TAG, "bridge init OK — seed=%s zone=%s hb=%us ingest=%us",
s_cfg.seed_url, s_cfg.zone_name,
s_cfg.heartbeat_sec, s_cfg.ingest_sec);
return ESP_OK;
}
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals)
{
if (vitals == NULL || s_mutex == NULL) {
return;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(&s_vitals, vitals, sizeof(s_vitals));
s_vitals_valid = true;
xSemaphoreGive(s_mutex);
}
void swarm_bridge_update_happiness(const float *vector, uint8_t dim)
{
if (vector == NULL || s_mutex == NULL) {
return;
}
uint8_t n = (dim < SWARM_VECTOR_DIM) ? dim : SWARM_VECTOR_DIM;
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(s_happiness, vector, n * sizeof(float));
/* Zero-fill remaining dimensions. */
for (uint8_t i = n; i < SWARM_VECTOR_DIM; i++) {
s_happiness[i] = 0.0f;
}
xSemaphoreGive(s_mutex);
}
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
uint32_t *ingests, uint32_t *errors)
{
if (regs) *regs = s_cnt_regs;
if (heartbeats) *heartbeats = s_cnt_heartbeats;
if (ingests) *ingests = s_cnt_ingests;
if (errors) *errors = s_cnt_errors;
}
/* ---- HTTP POST helper ---- */
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
const char *json, int json_len)
{
esp_http_client_set_post_field(client, json, json_len);
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) {
/* Connection may have been closed by Seed between requests.
* Close our end and let the next perform() reconnect. */
esp_http_client_close(client);
/* Retry once. */
err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGW(TAG, "HTTP POST failed: %s", esp_err_to_name(err));
s_cnt_errors++;
esp_http_client_close(client);
return err;
}
}
int status = esp_http_client_get_status_code(client);
/* Close connection after each request to avoid stale keep-alive. */
esp_http_client_close(client);
if (status < 200 || status >= 300) {
ESP_LOGW(TAG, "HTTP POST status %d", status);
s_cnt_errors++;
return ESP_FAIL;
}
return ESP_OK;
}
/* ---- Get local IP address as string ---- */
static void swarm_get_ip_str(char *buf, size_t buf_len)
{
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif == NULL) {
snprintf(buf, buf_len, "0.0.0.0");
return;
}
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) {
snprintf(buf, buf_len, "0.0.0.0");
return;
}
snprintf(buf, buf_len, IPSTR, IP2STR(&ip_info.ip));
}
/* ---- Swarm bridge task ---- */
static void swarm_task(void *arg)
{
(void)arg;
/* Build the full ingest URL once. */
char url[128];
snprintf(url, sizeof(url), "%s%s", s_cfg.seed_url, SWARM_INGEST_PATH);
/* Create a reusable HTTP client. */
esp_http_client_config_t http_cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = SWARM_HTTP_TIMEOUT,
};
esp_http_client_handle_t client = esp_http_client_init(&http_cfg);
if (client == NULL) {
ESP_LOGE(TAG, "failed to create HTTP client — task exiting");
vTaskDelete(NULL);
return;
}
esp_http_client_set_header(client, "Content-Type", "application/json");
/* ADR-066: Set Bearer token for Seed WiFi auth (from pairing). */
if (s_cfg.seed_token[0] != '\0') {
char auth_hdr[80];
snprintf(auth_hdr, sizeof(auth_hdr), "Bearer %s", s_cfg.seed_token);
esp_http_client_set_header(client, "Authorization", auth_hdr);
ESP_LOGI(TAG, "Bearer token configured for Seed auth");
}
/* Get firmware version string. */
const esp_app_desc_t *app = esp_app_get_description();
const char *fw_ver = app ? app->version : "unknown";
/* Get local IP. */
char ip_str[16];
swarm_get_ip_str(ip_str, sizeof(ip_str));
/* ---- Registration POST ---- */
/* Seed ingest format: {"vectors":[[u64_id, [f32; dim]]]} */
{
/* ID scheme: node_id * 1000000 + type_code (0=reg, 1=hb, 2=happiness) */
uint32_t reg_id = (uint32_t)s_node_id * 1000000U;
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[0,0,0,0,0,0,0,0]]]}",
(unsigned long)reg_id);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_regs++;
ESP_LOGI(TAG, "registered node %u with seed (id=%lu)", s_node_id, (unsigned long)reg_id);
} else {
ESP_LOGW(TAG, "registration failed — will retry on next heartbeat");
}
}
/* ---- Main loop ---- */
TickType_t last_heartbeat = xTaskGetTickCount();
TickType_t last_ingest = xTaskGetTickCount();
const TickType_t poll_interval = pdMS_TO_TICKS(1000); /* Wake every 1 s. */
for (;;) {
vTaskDelay(poll_interval);
TickType_t now = xTaskGetTickCount();
/* Snapshot shared data under mutex. */
float hv[SWARM_VECTOR_DIM];
edge_vitals_pkt_t vit;
bool vit_valid;
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(hv, s_happiness, sizeof(hv));
memcpy(&vit, &s_vitals, sizeof(vit));
vit_valid = s_vitals_valid;
xSemaphoreGive(s_mutex);
uint32_t uptime_s = (uint32_t)(esp_timer_get_time() / 1000000ULL);
uint32_t free_heap = esp_get_free_heap_size();
uint32_t ts = (uint32_t)(esp_timer_get_time() / 1000ULL);
/* ---- Heartbeat ---- */
if ((now - last_heartbeat) >= pdMS_TO_TICKS(s_cfg.heartbeat_sec * 1000U)) {
last_heartbeat = now;
bool presence = vit_valid && (vit.flags & 0x01);
/* Heartbeat ID: node_id * 1000000 + 100000 + ts_sec */
uint32_t hb_id = (uint32_t)s_node_id * 1000000U + 100000U + (uptime_s % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)hb_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_heartbeats++;
}
}
/* ---- Happiness ingest (only when presence detected) ---- */
if ((now - last_ingest) >= pdMS_TO_TICKS(s_cfg.ingest_sec * 1000U)) {
last_ingest = now;
bool presence = vit_valid && (vit.flags & 0x01);
if (presence) {
/* Happiness ID: node_id * 1000000 + 200000 + ts_sec */
uint32_t h_id = (uint32_t)s_node_id * 1000000U + 200000U + (ts / 1000U % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)h_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_ingests++;
}
}
}
}
/* Unreachable, but clean up for completeness. */
esp_http_client_cleanup(client);
vTaskDelete(NULL);
}
@@ -0,0 +1,67 @@
/**
* @file swarm_bridge.h
* @brief ADR-066: ESP32 Swarm Bridge — Cognitum Seed coordinator client.
*
* Registers this node with a Cognitum Seed, sends periodic heartbeats,
* and pushes happiness vectors for cross-zone analytics.
* Runs as a FreeRTOS task on Core 0.
*/
#ifndef SWARM_BRIDGE_H
#define SWARM_BRIDGE_H
#include <stdint.h>
#include "esp_err.h"
#include "edge_processing.h"
/** Happiness vector dimension. */
#define SWARM_VECTOR_DIM 8
/** Swarm bridge configuration. */
typedef struct {
char seed_url[64]; /**< Cognitum Seed base URL (e.g. "http://192.168.1.10:8080"). */
char seed_token[64]; /**< Bearer token for Seed WiFi API auth (from pairing). */
char zone_name[16]; /**< Zone name for this node (e.g. "bedroom"). */
uint16_t heartbeat_sec; /**< Heartbeat interval in seconds (default 30). */
uint16_t ingest_sec; /**< Happiness ingest interval in seconds (default 5). */
uint8_t enabled; /**< 1 = bridge active, 0 = disabled. */
} swarm_config_t;
/**
* Initialize the swarm bridge and start the background task.
* Registers this node with the Cognitum Seed on first successful POST.
*
* @param cfg Swarm bridge configuration.
* @param node_id This node's identifier (from NVS).
* @return ESP_OK on success, ESP_ERR_INVALID_ARG if seed_url is empty.
*/
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id);
/**
* Feed the latest vitals packet into the swarm bridge.
* Called from the main loop whenever new vitals are available.
*
* @param vitals Pointer to the latest vitals packet.
*/
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals);
/**
* Update the happiness vector to be pushed at the next ingest cycle.
*
* @param vector Float array of happiness values.
* @param dim Number of elements (clamped to SWARM_VECTOR_DIM).
*/
void swarm_bridge_update_happiness(const float *vector, uint8_t dim);
/**
* Get cumulative bridge statistics.
*
* @param regs Output: number of successful registrations.
* @param heartbeats Output: number of successful heartbeats sent.
* @param ingests Output: number of successful happiness ingests sent.
* @param errors Output: number of HTTP errors encountered.
*/
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
uint32_t *ingests, uint32_t *errors);
#endif /* SWARM_BRIDGE_H */
+26
View File
@@ -71,6 +71,17 @@ def build_nvs_csv(args):
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
# NVS blob: write as hex-encoded string for CSV compatibility
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
# ADR-066: Swarm bridge configuration
if args.seed_url is not None:
writer.writerow(["seed_url", "data", "string", args.seed_url])
if args.seed_token is not None:
writer.writerow(["seed_token", "data", "string", args.seed_token])
if args.zone is not None:
writer.writerow(["zone_name", "data", "string", args.zone])
if args.swarm_hb is not None:
writer.writerow(["swarm_hb", "data", "u16", str(args.swarm_hb)])
if args.swarm_ingest is not None:
writer.writerow(["swarm_ingest", "data", "u16", str(args.swarm_ingest)])
return buf.getvalue()
@@ -170,6 +181,12 @@ def main():
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
"Overrides auto-detection from connected AP.")
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
# ADR-066: Swarm bridge
parser.add_argument("--seed-url", type=str, help="Cognitum Seed base URL (e.g. http://10.1.10.236)")
parser.add_argument("--seed-token", type=str, help="Seed Bearer token (from pairing)")
parser.add_argument("--zone", type=str, help="Zone name for this node (e.g. lobby, hallway)")
parser.add_argument("--swarm-hb", type=int, help="Swarm heartbeat interval in seconds (default 30)")
parser.add_argument("--swarm-ingest", type=int, help="Swarm vector ingest interval in seconds (default 5)")
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
args = parser.parse_args()
@@ -182,6 +199,7 @@ def main():
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
args.seed_url is not None, args.zone is not None,
])
if not has_value:
parser.error("At least one config value must be specified")
@@ -238,6 +256,14 @@ def main():
print(f" CSI Channel: {args.channel}")
if args.filter_mac is not None:
print(f" Filter MAC: {args.filter_mac}")
if args.seed_url is not None:
print(f" Seed URL: {args.seed_url}")
if args.zone is not None:
print(f" Zone: {args.zone}")
if args.swarm_hb is not None:
print(f" Swarm HB: {args.swarm_hb}s")
if args.swarm_ingest is not None:
print(f" Swarm Ingest: {args.swarm_ingest}s")
csv_content = build_nvs_csv(args)