commeownder/simulator/test_ble.c

934 lines
29 KiB
C

/* simulator/test_ble.c
*
* BLE integration tests for commeownder firmware.
*
* Spawns the simulator binary as fake BLE peers, reads the ESP32 serial port
* for "DBG ..." lines, and verifies correct BLE behaviour.
*
* Firmware must be compiled with -DDEBUG.
* Usage: ./test_ble [--port /dev/ttyUSB0] [--sim ./simulator]
*/
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
/* ── Compile-time defaults ────────────────────────────────────────────────── */
#ifndef SIM_BIN
# define SIM_BIN "./simulator"
#endif
static const char *g_port = "/dev/ttyUSB0";
static const char *g_sim_bin = SIM_BIN;
/* ── Firmware constants (must mirror main.c) ─────────────────────────────── */
#define DEFAULT_GAME_ID "4242"
#define WRONG_GAME_ID "BEEF"
#define BLE_TIMEOUT_S 30
#define SCAN_CYCLE_S 21
#define PEER_DETECT_S (SCAN_CYCLE_S + 12)
#define MAX_BLE_PEERS 4
/* ── Serial line queue ───────────────────────────────────────────────────── */
#define LBUF 512
#define QSIZE 256
typedef struct {
char data[QSIZE][LBUF];
int head, tail, count;
pthread_mutex_t mtx;
pthread_cond_t cond;
} lq_t;
static lq_t g_q;
static int g_serial_fd = -1;
static pthread_t g_serial_thr;
static volatile int g_serial_stop = 0;
static void lq_init(lq_t *q)
{
memset(q, 0, sizeof *q);
pthread_mutex_init(&q->mtx, NULL);
/* use CLOCK_MONOTONIC for timedwait so wall-clock adjustments don't bite */
pthread_condattr_t attr;
pthread_condattr_init(&attr);
pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);
pthread_cond_init(&q->cond, &attr);
pthread_condattr_destroy(&attr);
}
static void lq_push(lq_t *q, const char *line)
{
pthread_mutex_lock(&q->mtx);
if (q->count < QSIZE) {
strncpy(q->data[q->tail], line, LBUF - 1);
q->data[q->tail][LBUF - 1] = '\0';
q->tail = (q->tail + 1) % QSIZE;
q->count++;
pthread_cond_signal(&q->cond);
}
pthread_mutex_unlock(&q->mtx);
}
static void lq_drain(lq_t *q)
{
pthread_mutex_lock(&q->mtx);
q->head = q->tail = q->count = 0;
pthread_mutex_unlock(&q->mtx);
}
/*
* Block until a queued line contains ALL strings in the NULL-terminated
* `pats` array, or `secs` seconds elapse. Returns 1 on match, 0 on timeout.
* Non-matching lines are consumed and discarded.
* If `out` != NULL, the matching line is copied there.
*/
static int lq_wait(lq_t *q, const char **pats, int secs, char *out)
{
struct timespec dl;
clock_gettime(CLOCK_MONOTONIC, &dl);
dl.tv_sec += secs;
pthread_mutex_lock(&q->mtx);
for (;;) {
while (q->count > 0) {
char *line = q->data[q->head];
q->head = (q->head + 1) % QSIZE;
q->count--;
int ok = 1;
for (int i = 0; pats[i]; i++)
if (!strstr(line, pats[i])) { ok = 0; break; }
if (ok) {
if (out) strncpy(out, line, LBUF - 1);
pthread_mutex_unlock(&q->mtx);
return 1;
}
}
if (pthread_cond_timedwait(&q->cond, &q->mtx, &dl) == ETIMEDOUT) {
pthread_mutex_unlock(&q->mtx);
return 0;
}
}
}
/* Convenience: wait for one pattern */
static int wait1(const char *p, int secs, char *out)
{
const char *pats[] = {p, NULL};
return lq_wait(&g_q, pats, secs, out);
}
/* Convenience: wait for two patterns in the same line */
static int wait2(const char *p1, const char *p2, int secs, char *out)
{
const char *pats[] = {p1, p2, NULL};
return lq_wait(&g_q, pats, secs, out);
}
/* ── Serial reader thread ─────────────────────────────────────────────────── */
static void *serial_reader(void *arg)
{
(void)arg;
char line[LBUF];
int pos = 0;
while (!g_serial_stop) {
char c;
ssize_t n = read(g_serial_fd, &c, 1);
if (n == 0) continue; /* VTIME timeout — no data yet */
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) { usleep(1000); continue; }
break;
}
if (c == '\n' || c == '\r') {
if (pos > 0) {
line[pos] = '\0';
if (strncmp(line, "DBG ", 4) == 0)
lq_push(&g_q, line);
pos = 0;
}
} else if (pos < LBUF - 1) {
line[pos++] = c;
}
}
return NULL;
}
static int open_serial(const char *port)
{
int fd = open(port, O_RDWR | O_NOCTTY);
if (fd < 0) { perror("open"); return -1; }
struct termios tty;
memset(&tty, 0, sizeof tty);
tty.c_cflag = CS8 | CREAD | CLOCAL;
cfsetispeed(&tty, B115200);
cfsetospeed(&tty, B115200);
tty.c_oflag = 0;
tty.c_lflag = 0;
tty.c_cc[VMIN] = 0;
tty.c_cc[VTIME] = 1; /* 0.1 s read timeout */
if (tcsetattr(fd, TCSANOW, &tty) < 0) { perror("tcsetattr"); close(fd); return -1; }
return fd;
}
static void serial_send(const char *cmd)
{
size_t len = strlen(cmd);
if (write(g_serial_fd, cmd, len) < 0) perror("serial_send");
}
/* ── Child process tracking ──────────────────────────────────────────────── */
#define MAX_CHILDREN 32
static pid_t g_children[MAX_CHILDREN];
static int g_nchildren = 0;
static void register_child(pid_t pid)
{
if (g_nchildren < MAX_CHILDREN)
g_children[g_nchildren++] = pid;
}
static void kill_all_children(void)
{
for (int i = 0; i < g_nchildren; i++) {
if (g_children[i] > 0) {
kill(g_children[i], SIGTERM);
waitpid(g_children[i], NULL, WNOHANG);
g_children[i] = 0;
}
}
g_nchildren = 0;
}
static void sig_cleanup(int sig)
{
(void)sig;
kill_all_children();
_exit(1);
}
/* ── Simulator subprocess helpers ─────────────────────────────────────────── */
static pid_t sim_start_ex(const char *name, int life, int poison,
const char *game_id, int eliminated, int reset_cmd)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); return -1; }
if (pid == 0) {
close(g_serial_fd);
char lbuf[16], pbuf[16], n8[9];
snprintf(lbuf, sizeof lbuf, "%d", life);
snprintf(pbuf, sizeof pbuf, "%d", poison);
strncpy(n8, name, 8); n8[8] = '\0';
const char *args[16];
int ai = 0;
args[ai++] = g_sim_bin;
args[ai++] = "--name"; args[ai++] = n8;
args[ai++] = "--life"; args[ai++] = lbuf;
args[ai++] = "--poison"; args[ai++] = pbuf;
args[ai++] = "--game-id"; args[ai++] = game_id;
if (eliminated) args[ai++] = "--eliminated";
if (reset_cmd) args[ai++] = "--reset-cmd";
args[ai] = NULL;
execv(g_sim_bin, (char *const *)args);
_exit(1);
}
register_child(pid);
return pid;
}
static pid_t sim_start(const char *name, int life, int poison,
const char *game_id, int eliminated)
{
return sim_start_ex(name, life, poison, game_id, eliminated, 0);
}
static void sim_stop(pid_t pid)
{
if (pid <= 0) return;
kill(pid, SIGTERM);
waitpid(pid, NULL, 0);
for (int i = 0; i < g_nchildren; i++)
if (g_children[i] == pid) { g_children[i] = 0; break; }
}
/* ── Tracked simulator: captures sim stdout into g_sim_q ─────────────────── */
static lq_t g_sim_q;
static int g_sim_pipe_fd = -1;
static pthread_t g_sim_thr;
static void *sim_pipe_reader(void *arg)
{
(void)arg;
char line[LBUF];
int pos = 0;
char c;
while (read(g_sim_pipe_fd, &c, 1) > 0) {
if (c == '\n' || c == '\r') {
if (pos > 0) {
line[pos] = '\0';
lq_push(&g_sim_q, line);
pos = 0;
}
} else if (pos < LBUF - 1) {
line[pos++] = c;
}
}
return NULL;
}
static pid_t sim_start_tracked(const char *name, int life, int poison,
const char *game_id)
{
int pipefd[2];
if (pipe(pipefd) < 0) { perror("pipe"); return -1; }
g_sim_pipe_fd = pipefd[0];
pid_t pid = fork();
if (pid < 0) { perror("fork"); close(pipefd[0]); close(pipefd[1]); return -1; }
if (pid == 0) {
close(g_serial_fd);
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
char lbuf[16], pbuf[16], n8[9];
snprintf(lbuf, sizeof lbuf, "%d", life);
snprintf(pbuf, sizeof pbuf, "%d", poison);
strncpy(n8, name, 8); n8[8] = '\0';
const char *args[] = {
g_sim_bin,
"--name", n8,
"--life", lbuf,
"--poison", pbuf,
"--game-id", game_id,
NULL
};
execv(g_sim_bin, (char *const *)args);
_exit(1);
}
close(pipefd[1]);
register_child(pid);
lq_drain(&g_sim_q);
pthread_create(&g_sim_thr, NULL, sim_pipe_reader, NULL);
return pid;
}
static void sim_stop_tracked(pid_t pid)
{
sim_stop(pid);
pthread_join(g_sim_thr, NULL);
close(g_sim_pipe_fd);
g_sim_pipe_fd = -1;
}
static int sim_wait1(const char *p, int secs)
{
const char *pats[] = {p, NULL};
return lq_wait(&g_sim_q, pats, secs, NULL);
}
static int sim_wait2(const char *p1, const char *p2, int secs)
{
const char *pats[] = {p1, p2, NULL};
return lq_wait(&g_sim_q, pats, secs, NULL);
}
/* ── Key-value parsing ───────────────────────────────────────────────────── */
static int kv_int(const char *line, const char *key, int *out)
{
char s[64];
snprintf(s, sizeof s, "%s=", key);
const char *p = strstr(line, s);
if (!p) return 0;
*out = atoi(p + strlen(s));
return 1;
}
static int kv_str(const char *line, const char *key, char *out, int n)
{
char s[64];
snprintf(s, sizeof s, "%s=", key);
const char *p = strstr(line, s);
if (!p) return 0;
p += strlen(s);
int i = 0;
while (*p && *p != ' ' && i < n - 1) out[i++] = *p++;
out[i] = '\0';
return 1;
}
/* Parse "[a,b,c]" bracket-list value for key= into out[0..n-1]. */
static int kv_intlist(const char *line, const char *key, int *out, int n)
{
char s[64];
snprintf(s, sizeof s, "%s=[", key);
const char *p = strstr(line, s);
if (!p) return 0;
p += strlen(s);
for (int i = 0; i < n; i++) {
out[i] = atoi(p);
while (*p && *p != ',' && *p != ']') p++;
if (*p == ',') p++;
}
return 1;
}
/* ── Test framework ──────────────────────────────────────────────────────── */
static int g_pass = 0, g_fail = 0;
#define FAIL_MSG(fmt, ...) \
fprintf(stderr, "\n FAIL: " fmt "\n", ##__VA_ARGS__)
#define CHECK(cond) \
do { if (!(cond)) { FAIL_MSG("CHECK(%s)", #cond); return 1; } } while (0)
#define CHECK_EQ(a, b) \
do { \
int _a = (a), _b = (b); \
if (_a != _b) { FAIL_MSG("%s = %d, want %d", #a, _a, _b); return 1; } \
} while (0)
#define CHECK_STR_EQ(a, b) \
do { \
if (strcmp((a), (b))) { FAIL_MSG("%s = '%s', want '%s'", #a, (a), (b)); return 1; } \
} while (0)
#define CHECK_SEEN(pat, secs, linebuf) \
do { \
if (!wait1((pat), (secs), (linebuf))) { \
FAIL_MSG("timeout waiting for: %s", (pat)); return 1; \
} \
} while (0)
#define CHECK_SEEN2(p1, p2, secs, linebuf) \
do { \
if (!wait2((p1), (p2), (secs), (linebuf))) { \
FAIL_MSG("timeout waiting for: %s + %s", (p1), (p2)); return 1; \
} \
} while (0)
#define CHECK_NOT_SEEN(pat, secs) \
do { \
if (wait1((pat), (secs), NULL)) { \
FAIL_MSG("unexpected line: %s", (pat)); return 1; \
} \
} while (0)
#define CHECK_NOT_SEEN2(p1, p2, secs) \
do { \
if (wait2((p1), (p2), (secs), NULL)) { \
FAIL_MSG("unexpected line: %s + %s", (p1), (p2)); return 1; \
} \
} while (0)
#define RUN(fn) \
do { \
kill_all_children(); \
fprintf(stderr, " %-55s", #fn); \
lq_drain(&g_q); \
int _r = fn(); \
if (_r == 0) { g_pass++; fputs(" PASS\n", stderr); } \
else { g_fail++; fputs(" FAIL\n", stderr); } \
} while (0)
/* ── Tests ───────────────────────────────────────────────────────────────── */
/* Device must emit DBG STATE at boot with clean starting values. */
static int t_startup_state(void)
{
char line[LBUF];
int life, poison, eliminated, cmdr[4], counters[3];
serial_send("STATE\n");
CHECK_SEEN("DBG STATE", 15, line);
CHECK(kv_int(line, "life", &life));
CHECK(kv_int(line, "poison", &poison));
CHECK(kv_int(line, "eliminated", &eliminated));
CHECK(life == 20 || life == 30 || life == 40);
CHECK_EQ(poison, 0);
CHECK_EQ(eliminated, 0);
if (kv_intlist(line, "cmdr", cmdr, 4)) {
CHECK_EQ(cmdr[0], 0); CHECK_EQ(cmdr[1], 0);
CHECK_EQ(cmdr[2], 0); CHECK_EQ(cmdr[3], 0);
}
if (kv_intlist(line, "counters", counters, 3)) {
CHECK_EQ(counters[0], 0); CHECK_EQ(counters[1], 0); CHECK_EQ(counters[2], 0);
}
return 0;
}
/* Device must emit ADV_TX with all required fields present and sane. */
static int t_adv_tx_fields(void)
{
char line[LBUF], gid[16];
int life, poison, eliminated;
CHECK_SEEN("DBG ADV_TX", 25, line);
CHECK(kv_int(line, "life", &life));
CHECK(kv_int(line, "poison", &poison));
CHECK(kv_int(line, "eliminated", &eliminated));
CHECK(kv_str(line, "game_id", gid, sizeof gid));
CHECK(life > 0);
CHECK_EQ(poison, 0);
CHECK_EQ(eliminated, 0);
CHECK_STR_EQ(gid, DEFAULT_GAME_ID);
return 0;
}
/* ADV_TX must reflect the same life/poison/eliminated as DBG STATE. */
static int t_state_adv_consistent(void)
{
char sl[LBUF], al[LBUF];
int sl_life, al_life, sl_poi, al_poi, sl_elim, al_elim;
serial_send("STATE\n");
CHECK_SEEN("DBG STATE", 15, sl);
CHECK_SEEN("DBG ADV_TX", 25, al);
CHECK(kv_int(sl, "life", &sl_life));
CHECK(kv_int(al, "life", &al_life));
CHECK(kv_int(sl, "poison", &sl_poi));
CHECK(kv_int(al, "poison", &al_poi));
CHECK(kv_int(sl, "eliminated", &sl_elim));
CHECK(kv_int(al, "eliminated", &al_elim));
CHECK_EQ(al_life, sl_life);
CHECK_EQ(al_poi, sl_poi);
CHECK_EQ(al_elim, sl_elim);
return 0;
}
/* Device must log PEER_RX when a peer with the correct game_id advertises. */
static int t_peer_rx_detected(void)
{
char line[LBUF];
int slot;
pid_t sim = sim_start("PEER1", 35, 0, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "PEER1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "slot", &slot));
CHECK(slot >= 0 && slot < MAX_BLE_PEERS);
return 0;
}
/* PEER_RX life value must exactly match the simulator's advertised life. */
static int t_peer_rx_life_matches(void)
{
char line[LBUF];
int life;
pid_t sim = sim_start("LIFECHK1", 27, 0, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "LIFECHK1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "life", &life));
CHECK_EQ(life, 27);
return 0;
}
/* PEER_RX poison value must exactly match the simulator's advertised poison. */
static int t_peer_rx_poison_matches(void)
{
char line[LBUF];
int poison;
pid_t sim = sim_start("POSNCHK1", 40, 5, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "POSNCHK1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "poison", &poison));
CHECK_EQ(poison, 5);
return 0;
}
/* PEER_RX eliminated=1 when simulator advertises as eliminated. */
static int t_peer_rx_eliminated_set(void)
{
char line[LBUF];
int elim;
pid_t sim = sim_start("ELIMCHK1", 0, 0, DEFAULT_GAME_ID, 1);
int found = wait2("DBG PEER_RX", "ELIMCHK1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "eliminated", &elim));
CHECK_EQ(elim, 1);
return 0;
}
/* PEER_RX eliminated=0 when simulator advertises a live player. */
static int t_peer_rx_eliminated_clear(void)
{
char line[LBUF];
int elim;
pid_t sim = sim_start("ALIVCHK1", 40, 0, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "ALIVCHK1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "eliminated", &elim));
CHECK_EQ(elim, 0);
return 0;
}
/* PEER_RX must NOT appear for a peer advertising a different game_id. */
static int t_game_id_filter_wrong_rejected(void)
{
pid_t sim = sim_start("WRONGID1", 40, 0, WRONG_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "WRONGID1", PEER_DETECT_S, NULL);
sim_stop(sim);
if (found) { FAIL_MSG("wrong game_id peer was logged as PEER_RX"); return 1; }
return 0;
}
/* A valid peer is still discovered alongside an invalid one. */
static int t_game_id_filter_correct_accepted(void)
{
pid_t bad = sim_start("WRONGID2", 40, 0, WRONG_GAME_ID, 0);
pid_t good = sim_start("RIGHTID1", 40, 0, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "RIGHTID1", PEER_DETECT_S, NULL);
sim_stop(good);
sim_stop(bad);
CHECK(found);
return 0;
}
/*
* After the simulator stops, device must emit PEER_EXPIRE within
* BLE_PEER_TIMEOUT ticks (30 s) plus the 1 s expiry-check granularity.
*/
static int t_peer_timeout_expires(void)
{
char line[LBUF];
int slot;
pid_t sim = sim_start("TIMEOUT1", 40, 0, DEFAULT_GAME_ID, 0);
/* wait until device first sees the peer */
CHECK(wait2("DBG PEER_RX", "TIMEOUT1", PEER_DETECT_S, NULL));
sim_stop(sim);
/* wait for expiry: 30 s timeout + 1 s check granularity + 3 s slack */
CHECK_SEEN2("DBG PEER_EXPIRE", "TIMEOUT1", BLE_TIMEOUT_S + 4, line);
CHECK(kv_int(line, "slot", &slot));
CHECK(slot >= 0 && slot < MAX_BLE_PEERS);
return 0;
}
/*
* A continuously advertising peer must NOT be expired within the timeout
* window (checked over BLE_TIMEOUT_S - 5 s to give clear headroom).
*/
static int t_active_peer_not_expired(void)
{
pid_t sim = sim_start("NOEXP1", 40, 0, DEFAULT_GAME_ID, 0);
/* wait until device sees the peer at least once */
CHECK(wait2("DBG PEER_RX", "NOEXP1", PEER_DETECT_S, NULL));
/* must not expire in the window */
int expired = wait2("DBG PEER_EXPIRE", "NOEXP1", BLE_TIMEOUT_S - 5, NULL);
sim_stop(sim);
if (expired) { FAIL_MSG("active peer expired prematurely"); return 1; }
return 0;
}
/*
* When a peer advertises reset_cmd=1, the firmware must reset its counters
* (poison=0, eliminated=0, cmdr all 0) and emit a DBG STATE reflecting that.
*/
static int t_reset_cmd_resets_firmware(void)
{
char line[LBUF];
int cmdr[4], poison, elim;
pid_t sim = sim_start_ex("RSTALL1", 40, 0, DEFAULT_GAME_ID, 0, 1);
/* Wait until firmware logs PEER_RX carrying reset=1 */
int found = wait2("DBG PEER_RX", "reset=1", PEER_DETECT_S, NULL);
sim_stop(sim);
CHECK(found);
/* The very next DBG STATE must reflect the reset */
CHECK_SEEN("DBG STATE", 5, line);
CHECK(kv_int(line, "poison", &poison));
CHECK(kv_int(line, "eliminated", &elim));
CHECK_EQ(poison, 0);
CHECK_EQ(elim, 0);
if (kv_intlist(line, "cmdr", cmdr, 4)) {
CHECK_EQ(cmdr[0], 0); CHECK_EQ(cmdr[1], 0);
CHECK_EQ(cmdr[2], 0); CHECK_EQ(cmdr[3], 0);
}
return 0;
}
/* Serial SET life updates g_life and is reflected in DBG STATE. */
static int t_serial_set_life(void)
{
char line[LBUF]; int life;
serial_send("SET life=33\n");
CHECK_SEEN2("DBG STATE", "life=33", 5, line);
CHECK(kv_int(line, "life", &life));
CHECK_EQ(life, 33);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
return 0;
}
/* Serial SET cmdr0 updates commander damage and is reflected in DBG STATE. */
static int t_serial_set_cmdr(void)
{
char line[LBUF]; int cmdr[4];
serial_send("SET cmdr0=7\n");
CHECK_SEEN2("DBG STATE", "cmdr=[7,", 5, line);
CHECK(kv_intlist(line, "cmdr", cmdr, 4));
CHECK_EQ(cmdr[0], 7);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
return 0;
}
/* After SET life via serial, simulator sees updated life in firmware PEER adv. */
static int t_fw_life_syncs_to_sim(void)
{
serial_send("SET ble=1\n");
wait1("DBG STATE", 5, NULL);
serial_send("SET life=19\n");
wait1("DBG STATE", 5, NULL);
pid_t sim = sim_start_tracked("SYNCL1", 40, 0, DEFAULT_GAME_ID);
int found = sim_wait2("PEER", "life=19", PEER_DETECT_S);
sim_stop_tracked(sim);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
CHECK(found);
return 0;
}
/* After SET counter0 (poison) via serial, simulator sees it in firmware adv. */
static int t_fw_poison_syncs_to_sim(void)
{
serial_send("SET ble=1\n");
wait1("DBG STATE", 5, NULL);
serial_send("SET counter0=3\n");
wait1("DBG STATE", 5, NULL);
pid_t sim = sim_start_tracked("SYNCP1", 40, 0, DEFAULT_GAME_ID);
int found = sim_wait2("PEER", "poison=3", PEER_DETECT_S);
sim_stop_tracked(sim);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
CHECK(found);
return 0;
}
/* Firmware logs correct life from simulator PEER_RX. */
static int t_sim_life_syncs_to_fw(void)
{
char line[LBUF]; int life;
pid_t sim = sim_start("SLIFE1", 28, 0, DEFAULT_GAME_ID, 0);
int found = wait2("DBG PEER_RX", "SLIFE1", PEER_DETECT_S, line);
sim_stop(sim);
CHECK(found);
CHECK(kv_int(line, "life", &life));
CHECK_EQ(life, 28);
return 0;
}
/* After SET life=0 triggers elimination, simulator sees ELIMINATED in firmware adv. */
static int t_eliminated_syncs_to_adv(void)
{
serial_send("SET ble=1\n");
wait1("DBG STATE", 5, NULL);
serial_send("SET life=0\n");
wait1("DBG STATE", 5, NULL);
pid_t sim = sim_start_tracked("ELIMSYN1", 40, 0, DEFAULT_GAME_ID);
int found = sim_wait2("PEER", "ELIMINATED", PEER_DETECT_S);
sim_stop_tracked(sim);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
CHECK(found);
return 0;
}
/*
* Slowly steps life through every value 0→80→0, verifying serial state at
* each step. Intended for operator visual confirmation: watch OLED and LED
* sweep continuously through the full gradient.
*
* 0→40: red→green gradient
* 40→80: green→blue gradient
* <10: breathing pulse (speed increases toward 0)
* 0: eliminated (red pulse, BLE ELIMINATED flag)
*/
static int t_life_cycle_visual(void)
{
char line[LBUF];
int elim;
serial_send("SET ble=1\n");
if (!wait1("DBG STATE", 5, NULL)) { FAIL_MSG("device not responding"); return 1; }
lq_drain(&g_q);
pid_t sim = sim_start_tracked("VISLIFE1", 40, 0, DEFAULT_GAME_ID);
/* Ramp 0→80 then 80→0, one step at a time. */
static const struct { int from; int to; const char *label; } ramps[] = {
{ 0, 80, "[visual] life 0→80 (red→green→blue)" },
{ 79, 0, "[visual] life 80→0 (blue→green→red, breathe at <10)" },
};
for (int r = 0; r < 2; r++) {
fprintf(stderr, "\n %s\n", ramps[r].label);
int step = ramps[r].from <= ramps[r].to ? 1 : -1;
for (int v = ramps[r].from; v != ramps[r].to + step; v += step) {
char cmd[32], pat[32];
snprintf(cmd, sizeof cmd, "SET life=%d\n", v);
snprintf(pat, sizeof pat, "life=%d ", v);
lq_drain(&g_q);
serial_send(cmd);
if (!wait2("DBG STATE", pat, 2, line)) {
sim_stop_tracked(sim);
FAIL_MSG("no DBG STATE for life=%d", v);
return 1;
}
usleep(50000);
}
}
/* life=0 must have set eliminated */
CHECK(kv_int(line, "eliminated", &elim));
CHECK_EQ(elim, 1);
/* BLE: sim must see ELIMINATED within one scan cycle */
if (!sim_wait2("PEER", "ELIMINATED", PEER_DETECT_S)) {
sim_stop_tracked(sim);
FAIL_MSG("sim did not see ELIMINATED over BLE");
return 1;
}
sim_stop_tracked(sim);
serial_send("RESET\n");
wait1("DBG STATE", 5, NULL);
return 0;
}
/* ── Entry point ─────────────────────────────────────────────────────────── */
int main(int argc, char *argv[])
{
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--port") && i + 1 < argc)
g_port = argv[++i];
else if (!strcmp(argv[i], "--sim") && i + 1 < argc)
g_sim_bin = argv[++i];
else {
fprintf(stderr, "usage: %s [--port DEV] [--sim PATH]\n", argv[0]);
return 1;
}
}
signal(SIGINT, sig_cleanup);
signal(SIGTERM, sig_cleanup);
/* Kill any orphaned simulator processes left by a previous test run. */
{
const char *bn = strrchr(g_sim_bin, '/');
bn = bn ? bn + 1 : g_sim_bin;
char cmd[256];
snprintf(cmd, sizeof cmd, "pkill -x '%s' 2>/dev/null", bn);
system(cmd);
usleep(500000);
}
g_serial_fd = open_serial(g_port);
if (g_serial_fd < 0) {
fprintf(stderr, "cannot open serial port: %s\n", g_port);
return 1;
}
lq_init(&g_q);
lq_init(&g_sim_q);
pthread_create(&g_serial_thr, NULL, serial_reader, NULL);
fprintf(stderr, "\ncommeownder BLE integration tests\n");
fprintf(stderr, "port: %s sim: %s\n\n", g_port, g_sim_bin);
/* ── Device setup: wipe NVS, confirm clean state, enable BLE ────────── */
fprintf(stderr, " setup: CLEARNVS + SET ble=1 ...");
/* Opening the port can reset the ESP32 (DTR pulse through auto-reset
* circuit). Poll with STATE until the device responds, then send
* CLEARNVS so we don't lose the command while it's still booting. */
{
int ready = 0;
for (int i = 0; i < 10 && !ready; i++) {
serial_send("STATE\n");
if (wait1("DBG STATE", 3, NULL)) ready = 1;
}
if (!ready) {
fprintf(stderr, " FAIL (device not responding)\n");
return 1;
}
lq_drain(&g_q);
}
serial_send("CLEARNVS\n");
if (!wait1("DBG STATE", 10, NULL)) {
fprintf(stderr, " FAIL (no response to CLEARNVS)\n");
return 1;
}
serial_send("SET ble=1\n");
if (!wait1("DBG STATE", 5, NULL)) {
fprintf(stderr, " FAIL (no response to SET ble=1)\n");
return 1;
}
lq_drain(&g_q);
fprintf(stderr, " OK\n\n");
/* ── Advertising & state ────────────────────────────────────────────── */
RUN(t_startup_state);
RUN(t_adv_tx_fields);
RUN(t_state_adv_consistent);
/* ── Peer discovery & field accuracy ────────────────────────────────── */
RUN(t_peer_rx_detected);
RUN(t_peer_rx_life_matches);
RUN(t_peer_rx_poison_matches);
RUN(t_peer_rx_eliminated_set);
RUN(t_peer_rx_eliminated_clear);
/* ── Game-ID filtering ──────────────────────────────────────────────── */
RUN(t_game_id_filter_wrong_rejected);
RUN(t_game_id_filter_correct_accepted);
/* ── Reset all ──────────────────────────────────────────────────────── */
RUN(t_reset_cmd_resets_firmware);
/* ── Peer timeout ───────────────────────────────────────────────────── */
RUN(t_peer_timeout_expires);
RUN(t_active_peer_not_expired);
/* ── Serial control ─────────────────────────────────────────────────── */
RUN(t_serial_set_life);
RUN(t_serial_set_cmdr);
/* ── Bidirectional sync ─────────────────────────────────────────────── */
RUN(t_fw_life_syncs_to_sim);
RUN(t_fw_poison_syncs_to_sim);
RUN(t_sim_life_syncs_to_fw);
RUN(t_eliminated_syncs_to_adv);
/* ── Visual life-cycle (operator can watch OLED + LED) ─────────────────── */
RUN(t_life_cycle_visual);
fprintf(stderr, "\n%d passed, %d failed\n", g_pass, g_fail);
g_serial_stop = 1;
pthread_join(g_serial_thr, NULL);
close(g_serial_fd);
return g_fail > 0 ? 1 : 0;
}