project
Ghost Hand
Public Unreviewedby John Lauer
A small battery gadget that keeps an automatic sink faucet running while you use the sink: it learns the faucet's timeout, re-arms with the shortest blip, and stops after 10 minutes or when picked up.
main
John Lauer
Studio: project overview, how it works, lights, charging, battery life, firmware source, dashboard screenshots; licenseā¦
c2eaf61
3d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
/*
* test_learn.c : host-side tests of src/learn.c against simulated faucets
*
* Build and run: make -C test (or: gcc -O2 -Wall -I../src test_learn.c ../src/learn.c)
*
* The faucet model (one interrogation poll at a time):
* IDLE water off, polls every P_idle. A reflection -> CONFIRM.
* CONFIRM polls at the active rate; a second reflection -> ACTIVE (water on,
* safety timer starts), otherwise back to IDLE.
* ACTIVE water on. Reflection: if the timer reached T -> LOCKOUT (water off).
* miss_n polls in a row without a reflection -> RUNON.
* RUNON water still on for D ms (run-on). A reflection -> ACTIVE with the
* timer restarted (a "new user"), else after D -> IDLE (water off).
* LOCKOUT timed out, water off, the reflection is treated as a still object;
* miss_n missed polls -> IDLE.
* Faucets "without observable rate change" poll at P_idle in every state.
* Each poll period has +/-3 % pseudo-random jitter.
*
* The harness reproduces the firmware's timing: pulses are timestamped when
* they happen but handed to the learner on the next 16 ms main-loop tick, and
* the echo enable only changes on ticks. Each session is 10 min (the cap) with
* a 2 s listen first; the learner's store is carried between sessions exactly
* like the EEPROM.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "learn.h"
#define SESSION_MS 600000u
#define TICK_MS 16u
#define N_SESSIONS 6
typedef enum { F_IDLE, F_CONFIRM, F_ACTIVE, F_RUNON, F_LOCKOUT } fstate_t;
typedef struct {
const char *name;
uint32_t T; /* safety timeout, ms */
uint32_t p_idle, p_act;
int rate_change;
int miss_n; /* missed polls before "hand gone" */
uint32_t D; /* run-on after hand gone, ms */
int runon_fast;/* polls at the active rate during run-on */
} faucet_cfg_t;
typedef struct {
const faucet_cfg_t *c;
fstate_t st;
uint32_t next_poll, t_on, t_gone;
int hits, misses;
int water;
} faucet_t;
static uint32_t rng = 12345;
static uint32_t rnd(void) { rng = rng * 1103515245u + 12345u; return (rng >> 8) & 0xFFFF; }
static uint32_t jit(uint32_t p) { return p - p * 3 / 100 + (uint32_t)((uint64_t)p * 6 / 100 * rnd() / 0xFFFF); }
static uint32_t period(const faucet_t *f)
{
if (!f->c->rate_change) return f->c->p_idle;
switch (f->st) {
case F_CONFIRM: case F_ACTIVE: return f->c->p_act;
case F_RUNON: return f->c->runon_fast ? f->c->p_act : f->c->p_idle;
default: return f->c->p_idle;
}
}
static void faucet_reset(faucet_t *f, const faucet_cfg_t *c, uint32_t t)
{
memset(f, 0, sizeof *f);
f->c = c; f->st = F_IDLE; f->next_poll = t + rnd() % c->p_idle;
}
/* one poll at time t, present = our echo answered it */
static void faucet_poll(faucet_t *f, uint32_t t, int present)
{
switch (f->st) {
case F_IDLE:
if (present) { f->st = F_CONFIRM; f->hits = 1; }
break;
case F_CONFIRM:
if (present) { f->st = F_ACTIVE; f->water = 1; f->t_on = t; f->misses = 0; }
else f->st = F_IDLE;
break;
case F_ACTIVE:
if (present) {
f->misses = 0;
if (t - f->t_on >= f->c->T) { f->st = F_LOCKOUT; f->water = 0; }
} else if (++f->misses >= f->c->miss_n) {
f->misses = 0;
if (f->c->D) { f->st = F_RUNON; f->t_gone = t; }
else { f->st = F_IDLE; f->water = 0; }
}
break;
case F_RUNON:
if (present) { f->st = F_ACTIVE; f->t_on = t; f->misses = 0; }
else if (t - f->t_gone >= f->c->D) { f->st = F_IDLE; f->water = 0; }
break;
case F_LOCKOUT:
if (present) f->misses = 0;
else if (++f->misses >= f->c->miss_n) { f->st = F_IDLE; f->misses = 0; }
break;
}
f->next_poll = t + jit(period(f));
}
typedef struct {
int drops; /* water on -> off transitions after the first on */
uint32_t off_ms; /* water off time after the first on */
uint32_t first_on;
uint32_t drop_t[64];
} sess_stats_t;
/* run one session; returns stats, updates store */
static void run_session(const faucet_cfg_t *c, learn_store_t *store, sess_stats_t *st,
learn_t *L, int verbose)
{
faucet_t f;
learn_listen_t ls;
uint32_t t = 0, q[64]; int nq = 0;
memset(st, 0, sizeof *st);
faucet_reset(&f, c, 0);
/* listen 2 s (extend to 5 s while fewer than 3 pulses), echo off */
learn_listen_init(&ls);
for (t = 0; t < 5000; t++) {
if (t >= f.next_poll) { faucet_poll(&f, t, 0); learn_listen_pulse(&ls, t); }
if (t >= 2000 && ls.n_pulses >= 3) break;
}
uint16_t pid = learn_listen_period(&ls);
learn_begin(L, store, pid, t);
int echo = learn_echo_on(L), water_prev = 0, ever_on = 0;
for (; t < SESSION_MS; t++) {
if (t >= f.next_poll) {
faucet_poll(&f, t, echo);
if (nq < 64) q[nq++] = t;
}
if (t % TICK_MS == 0) {
for (int i = 0; i < nq; i++) learn_pulse(L, q[i]);
nq = 0;
learn_tick(L, t);
uint8_t ev = learn_take_events(L);
if (ev & LEARN_EV_STORE_DIRTY) *store = *learn_store(L);
if (verbose && (ev & (LEARN_EV_BLIP_OK | LEARN_EV_BLIP_MISS | LEARN_EV_TIMEOUT_LEARNT | LEARN_EV_MODE)))
printf(" t=%6.1fs %s%s%s%s timeout=%u blip=%u floor=%u margin=%u\n", t / 1000.0,
ev & LEARN_EV_MODE ? "[mode] " : "", ev & LEARN_EV_TIMEOUT_LEARNT ? "[timeout] " : "",
ev & LEARN_EV_BLIP_OK ? "[blip ok] " : "", ev & LEARN_EV_BLIP_MISS ? "[blip MISS] " : "",
L->s.timeout_ms, L->s.blip_ms, L->s.blip_floor_ms, L->s.margin_ms);
echo = learn_echo_on(L);
}
if (f.water && !ever_on) { ever_on = 1; st->first_on = t; }
if (ever_on) {
if (!f.water) st->off_ms++;
if (water_prev && !f.water) { if (st->drops < 64) st->drop_t[st->drops] = t; st->drops++; }
}
water_prev = f.water;
}
*store = *learn_store(L);
}
typedef struct { int fails; int checks; } tally_t;
static void check(tally_t *ty, int ok, const char *what)
{
ty->checks++;
if (!ok) { ty->fails++; printf(" FAIL: %s\n", what); }
else printf(" pass: %s\n", what);
}
static void run_faucet(const faucet_cfg_t *c, learn_store_t *store, tally_t *ty, int fresh, int verbose)
{
learn_t L;
sess_stats_t st;
learn_store_t s0;
int late_drops = 0;
int floor_session = -1;
if (fresh) learn_store_defaults(store);
s0 = *store;
(void)s0;
printf("\n=== %s: T=%us idle=%ums active=%ums rate_change=%s release=%d polls run-on=%ums ===\n",
c->name, c->T / 1000, c->p_idle, c->p_act, c->rate_change ? "yes" : "no", c->miss_n, c->D);
for (int s = 0; s < N_SESSIONS; s++) {
uint16_t floor_before = store->blip_floor_ms;
run_session(c, store, &st, &L, verbose && s == 0);
printf(" session %d: mode=%s drops=%d water-off=%.1fs (after first on at %.1fs) blips=%u ok=%u miss=%u"
" -> timeout=%.2fs blip=%ums floor=%ums margin=%ums\n",
s + 1, L.mode == LEARN_MODE_TRACK ? "TRACK" : L.mode == LEARN_MODE_FALLBACK ? "FALLBACK" : "CLASSIFY",
st.drops, st.off_ms / 1000.0, st.first_on / 1000.0, L.n_blips, L.n_ok, L.n_miss,
store->timeout_ms / 1000.0, store->blip_ms, store->blip_floor_ms, store->margin_ms);
if (st.drops) {
printf(" drops at:");
for (int i = 0; i < st.drops && i < 12; i++) printf(" %.1fs", st.drop_t[i] / 1000.0);
printf("\n");
}
if (floor_session < 0 && store->blip_floor_ms) floor_session = s;
if (s >= 3) late_drops += st.drops;
else if (floor_before && s > floor_session) late_drops += st.drops;
}
if (c->rate_change) {
int32_t err = (int32_t)store->timeout_ms - (int32_t)c->T;
uint32_t tol = 2 * c->p_act + 150;
char buf[200];
check(ty, L.mode == LEARN_MODE_TRACK, "rate change detected (TRACK mode)");
snprintf(buf, sizeof buf, "timeout estimate %.2f s within %.2f s of the true %.0f s (error %+d ms)",
store->timeout_ms / 1000.0, tol / 1000.0, c->T / 1000.0, err);
check(ty, (uint32_t)abs(err) <= tol, buf);
uint32_t rel = (uint32_t)c->miss_n * c->p_act; /* release threshold in time */
uint32_t hi = rel + 2 * c->p_act + 100;
snprintf(buf, sizeof buf, "blip %u ms converged near the release threshold %u ms (window %u..%u)",
store->blip_ms, rel, rel, hi);
check(ty, store->blip_ms >= rel && store->blip_ms <= hi, buf);
snprintf(buf, sizeof buf, "no water drop after learning (sessions after the floor was found, and 4..%d): %d",
N_SESSIONS, late_drops);
check(ty, late_drops == 0, buf);
} else {
char buf[160];
check(ty, L.mode == LEARN_MODE_FALLBACK, "no rate change -> FALLBACK (re-arm every 8 s)");
snprintf(buf, sizeof buf, "fallback keeps the water on: drops in sessions 4..%d = %d", N_SESSIONS, late_drops);
check(ty, late_drops == 0, buf);
snprintf(buf, sizeof buf, "last session water off %.1f s of %.0f s", st.off_ms / 1000.0, (SESSION_MS - st.first_on) / 1000.0);
check(ty, st.off_ms * 50 < SESSION_MS - st.first_on, buf);
}
}
int main(int argc, char **argv)
{
int verbose = argc > 1 && !strcmp(argv[1], "-v");
tally_t ty = { 0, 0 };
learn_store_t store;
static const faucet_cfg_t faucets[] = {
{ "TOTO-like 10 s", 10000, 500, 100, 1, 2, 1000, 1 },
{ "Moen-like 30 s", 30000, 800, 80, 1, 3, 800, 1 },
{ "Selectronic-like 55 s", 55000, 1500, 125, 1, 2, 1500, 1 },
{ "60 s, 2 s idle, 50 ms act", 60000, 2000, 50, 1, 4, 1000, 1 },
{ "30 s, run-on at idle rate", 30000, 600, 100, 1, 2, 1200, 0 },
{ "30 s, no rate change", 30000, 250, 250, 0, 2, 1000, 1 },
{ "10 s, no rate change", 10000, 300, 300, 0, 2, 1200, 1 },
};
for (unsigned i = 0; i < sizeof faucets / sizeof faucets[0]; i++)
run_faucet(&faucets[i], &store, &ty, 1, verbose);
/* moved to another faucet with the same idle period (fingerprint matches):
* the stored 55 s estimate must come down after one drop */
static const faucet_cfg_t a = { "swap: learnt on 55 s ...", 55000, 500, 100, 1, 2, 1000, 1 };
static const faucet_cfg_t b = { "... then moved to a 10 s faucet", 10000, 500, 100, 1, 2, 1000, 1 };
run_faucet(&a, &store, &ty, 1, 0);
run_faucet(&b, &store, &ty, 0, verbose);
/* EEPROM image round trip */
learn_store_t cp = store;
check(&ty, learn_store_valid(&cp), "store CRC valid");
cp.blip_ms ^= 1;
check(&ty, !learn_store_valid(&cp), "corrupted store rejected");
printf("\n%d checks, %d failed\n", ty.checks, ty.fails);
return ty.fails ? 1 : 0;
}