/* 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 #include #include #include #include #include #include #include #include #include #include #include /* ── 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; }