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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
/*
* learn.c : faucet timeout / blip estimator
*
* The problem
* -----------
* An automatic faucet runs while it sees a reflection, but only up to a safety
* timeout (10 to 60 s typical). After that it treats the reflection as a still
* object and shuts the water off until the "hand" goes away and comes back.
* Ghost Hand therefore has to make the hand go away briefly ("blip": stop
* echoing) before the timeout, so the faucet starts a fresh run without the
* water ever stopping. The blip must be long enough that the faucet notices
* the hand left (its release threshold, a few missed interrogations) and short
* enough that the water does not stop during it (its run-on delay).
*
* What we can observe
* -------------------
* Only the faucet's own IR interrogation pulses. Many faucets pulse faster
* while they see a user (active rate) and slower when idle or timed out (idle
* rate). That rate is the window into the faucet's internal state:
* - echo on and the rate drops to idle -> the faucet timed out (a "drop")
* - the time from activation to that drop -> the faucet's real timeout
*
* The estimator
* -------------
* 1. CLASSIFY: echo on. If an interval below 60 % of the idle period appears
* the faucet's rate is observable (TRACK), else FALLBACK.
* 2. TRACK, first time: echo until the drop. timeout = last fast pulse minus
* activation. Recovery blip, faucet restarts.
* 3. TRACK, learnt: re-arm at activation + timeout - margin (1.5 s): blip,
* then VERIFY: the faucet must be at the active rate again within ~1 s
* and must not drop until just past the old timeout (a blip that was too
* short does not reset the faucet's timer, which shows up as a drop right
* after the old timeout).
* success -> blip *= 0.85 (never below the floor, min 120 ms)
* miss -> blip *= 1.30, floor = max(1.15 * missed blip,
* last good blip + 1.1 active periods), margin += 0.5 s,
* and the observed timeout updates the estimate
* The floor is what stops the water dropping out once learnt: we only
* probe below the known good length until the first miss, then never
* again. "last good + one active period" guarantees the blip spans one
* more interrogation than a blip that worked, whatever the pulse phase.
* 4. EWMA of the timeout: up slowly (alpha 1/4), down quickly (alpha 3/4),
* because overestimating costs a water drop and underestimating only
* costs a slightly earlier blip.
* 5. FALLBACK (no observable rate change): blip every 8 s with
* max(blip, 2 idle periods + 50 ms) so at least two interrogations are
* missed. 8 s is under the shortest timeout we expect (10 s).
* 6. Everything that matters is stored (EEPROM) with the faucet's idle period
* as a fingerprint; a different faucet (idle period off by > 30 %) starts
* from the defaults.
*/
#include "learn.h"
#include <stddef.h>
/* ------------------------------------------------------------------ utils */
static uint16_t u16max(uint16_t a, uint16_t b) { return a > b ? a : b; }
static uint16_t u16min(uint16_t a, uint16_t b) { return a < b ? a : b; }
static uint16_t clamp16(uint32_t v, uint16_t lo, uint16_t hi)
{
if (v < lo) return lo;
if (v > hi) return hi;
return (uint16_t)v;
}
static uint8_t crc8(const uint8_t *p, uint8_t n)
{
uint8_t c = 0xFF;
while (n--) {
c ^= *p++;
for (uint8_t i = 0; i < 8; i++)
c = (c & 0x80) ? (uint8_t)((c << 1) ^ 0x31) : (uint8_t)(c << 1);
}
return c;
}
/* ------------------------------------------------------------- listening */
void learn_listen_init(learn_listen_t *l)
{
l->last = 0; l->n_iv = 0; l->n_pulses = 0;
}
void learn_listen_pulse(learn_listen_t *l, uint32_t t)
{
if (l->n_pulses) {
uint32_t iv = t - l->last;
if (l->n_iv < 8 && iv > 0 && iv < 60000u) l->iv[l->n_iv++] = (uint16_t)iv;
}
l->last = t;
if (l->n_pulses < 255) l->n_pulses++;
}
/* median of the measured intervals (robust to one missed or extra pulse) */
uint16_t learn_listen_period(const learn_listen_t *l)
{
uint16_t v[8];
uint8_t n = l->n_iv;
if (!n) return 0;
for (uint8_t i = 0; i < n; i++) v[i] = l->iv[i];
for (uint8_t i = 1; i < n; i++) { /* insertion sort, n <= 8 */
uint16_t x = v[i]; int8_t j = (int8_t)i - 1;
while (j >= 0 && v[j] > x) { v[j + 1] = v[j]; j--; }
v[j + 1] = x;
}
return v[n / 2];
}
/* ----------------------------------------------------------------- store */
uint8_t learn_store_crc(const learn_store_t *s)
{
return crc8((const uint8_t *)s, (uint8_t)offsetof(learn_store_t, crc));
}
bool learn_store_valid(const learn_store_t *s)
{
return s && s->magic == LEARN_STORE_MAGIC && s->version == LEARN_STORE_VERSION &&
s->crc == learn_store_crc(s);
}
void learn_store_defaults(learn_store_t *s)
{
s->magic = LEARN_STORE_MAGIC;
s->version = LEARN_STORE_VERSION;
s->observable = 0;
s->timeout_ms = 0;
s->blip_ms = LEARN_BLIP_START_MS;
s->blip_floor_ms = 0;
s->blip_last_ok_ms = 0;
s->margin_ms = LEARN_MARGIN_MS;
s->idle_period_ms = 0;
s->active_period_ms = 0;
s->crc = learn_store_crc(s);
}
const learn_store_t *learn_store(learn_t *L)
{
L->s.idle_period_ms = L->p_idle;
L->s.active_period_ms = L->p_act;
L->s.crc = learn_store_crc(&L->s);
return &L->s;
}
/* ------------------------------------------------------------- internals */
/* midpoint between active and idle period: faster than this = active rate */
static uint32_t thr_mid(const learn_t *L) { return ((uint32_t)L->p_idle + L->p_act) / 2; }
static bool is_fast(const learn_t *L, uint32_t iv)
{
if (L->mode == LEARN_MODE_TRACK) return iv < thr_mid(L);
return iv * LEARN_RATE_RATIO_DEN < (uint32_t)L->p_idle * LEARN_RATE_RATIO_NUM;
}
static void dirty(learn_t *L) { L->events |= LEARN_EV_STORE_DIRTY; }
static void start_blip(learn_t *L, uint16_t len, bool recovery)
{
L->phase = LEARN_PH_BLIP;
L->blip_cur = clamp16(len, LEARN_BLIP_MIN_MS, recovery ? LEARN_RECOVER_MAX_MS : LEARN_BLIP_MAX_MS);
L->t_phase_end = L->now + L->blip_cur;
L->recovering = recovery;
L->n_blips++;
L->events |= LEARN_EV_BLIP_START;
}
static void update_timeout(learn_t *L, uint32_t t_obs)
{
if (t_obs < LEARN_TIMEOUT_MIN_MS) return; /* not a timeout: lost echo, ignore */
uint16_t obs = clamp16(t_obs, LEARN_TIMEOUT_MIN_MS, LEARN_TIMEOUT_MAX_MS);
uint16_t est = L->s.timeout_ms;
if (!est || obs < est) est = obs; /* shorter: believe it at once */
else est = (uint16_t)(est + (obs - est) / 4u); /* longer: EWMA, alpha 1/4 */
L->s.timeout_ms = est;
L->timeout_measured = true;
L->events |= LEARN_EV_TIMEOUT_LEARNT;
dirty(L);
}
static void blip_ok(learn_t *L)
{
uint16_t b = L->blip_cur;
L->n_ok++;
L->s.blip_last_ok_ms = b;
uint16_t nb = (uint16_t)(((uint32_t)b * 85u) / 100u);
nb = u16max(nb, LEARN_BLIP_MIN_MS);
nb = u16max(nb, L->s.blip_floor_ms);
L->s.blip_ms = nb;
L->events |= LEARN_EV_BLIP_OK;
dirty(L);
}
static void blip_miss(learn_t *L)
{
uint16_t b = L->blip_cur;
L->n_miss++;
uint32_t fl = ((uint32_t)b * 115u) / 100u;
if (L->s.blip_last_ok_ms) {
uint32_t f2 = (uint32_t)L->s.blip_last_ok_ms + ((uint32_t)L->p_act * 11u) / 10u;
if (f2 > fl) fl = f2;
}
L->s.blip_floor_ms = u16max(L->s.blip_floor_ms, clamp16(fl, 0, LEARN_BLIP_MAX_MS));
uint16_t nb = clamp16(((uint32_t)b * 13u) / 10u, LEARN_BLIP_MIN_MS, LEARN_BLIP_MAX_MS);
L->s.blip_ms = u16max(nb, L->s.blip_floor_ms);
L->s.margin_ms = u16min((uint16_t)(L->s.margin_ms + LEARN_MARGIN_STEP_MS), LEARN_MARGIN_MAX_MS);
L->events |= LEARN_EV_BLIP_MISS;
dirty(L);
}
/* A timed-out faucet only re-arms after it has missed its release count of
* polls at the IDLE rate, so a recovery blip is sized from the idle period and
* grows on its own; it never touches the learnt (active-rate) blip. */
static void start_recovery(learn_t *L)
{
uint32_t r = 2u * (uint32_t)L->p_idle + 100u;
if (L->recover_ms > r) r = L->recover_ms;
if (L->s.blip_ms > r) r = L->s.blip_ms;
start_blip(L, clamp16(r, LEARN_BLIP_MIN_MS, LEARN_RECOVER_MAX_MS), true);
}
/* the faucet fell back to the idle rate (stopped seeing a user) */
static void on_drop(learn_t *L)
{
if (L->mode != LEARN_MODE_TRACK) return;
if (L->phase == LEARN_PH_ECHO) {
uint32_t t_obs = L->last_fast - L->t_act;
uint16_t prev = L->s.timeout_ms;
update_timeout(L, t_obs);
/* it timed out before our scheduled re-arm: re-arm earlier from now on */
if (prev && t_obs >= LEARN_TIMEOUT_MIN_MS && t_obs < prev) {
L->s.margin_ms = u16min((uint16_t)(L->s.margin_ms + LEARN_MARGIN_STEP_MS), LEARN_MARGIN_MAX_MS);
dirty(L);
}
L->n_drops++;
L->events |= LEARN_EV_DROP;
start_recovery(L); /* let it see us leave */
} else if (L->phase == LEARN_PH_VERIFY && L->seen_active_after) {
/* back on after the blip, then timed out anyway: the blip did not reset
* the faucet's timer. That drop also measures the timeout again. */
if (!L->recovering) {
update_timeout(L, L->last_fast - L->t_act);
blip_miss(L);
}
L->n_drops++;
L->events |= LEARN_EV_DROP;
start_recovery(L);
}
/* PH_BLIP: expected, judged at the end of VERIFY */
}
static void set_active(learn_t *L, bool a)
{
if (L->faucet_active == a) return;
L->faucet_active = a;
if (!a) on_drop(L);
else if (L->phase == LEARN_PH_VERIFY) L->seen_active_after = true;
}
/* ------------------------------------------------------------- interface */
void learn_begin(learn_t *L, const learn_store_t *stored, uint16_t idle, uint32_t now)
{
bool use = learn_store_valid(stored);
if (use && idle && stored->idle_period_ms) {
/* fingerprint: same faucet if the idle period is within 30 % */
uint32_t a = idle, b = stored->idle_period_ms;
uint32_t d = a > b ? a - b : b - a;
if (d * 10u > b * 3u) use = false;
}
if (use) L->s = *stored; else learn_store_defaults(&L->s);
L->p_idle = idle ? idle : (L->s.idle_period_ms ? L->s.idle_period_ms : LEARN_DEFAULT_IDLE_MS);
L->p_act = L->s.active_period_ms ? L->s.active_period_ms : (uint16_t)(L->p_idle / 3u);
if (L->p_act >= L->p_idle) L->p_act = (uint16_t)(L->p_idle / 3u);
L->mode = LEARN_MODE_CLASSIFY;
L->phase = LEARN_PH_ECHO;
L->events = 0;
L->now = now;
L->last_pulse = now; L->last_iv = 0; L->last_fast = now; L->npulses = 0;
L->faucet_active = false;
L->t_arm = now; L->t_act = now; L->t_act_cand = now; L->act_cand_set = false;
L->timeout_measured = false; L->recovering = false;
L->t_phase_end = L->t_return_by = L->t_verify_end = now;
L->t_classify_end = now + u16max(3000u, (uint16_t)u16min(20000u, (uint16_t)(2u * L->p_idle + 1000u)));
L->blip_cur = L->s.blip_ms;
L->recover_ms = 0;
L->seen_active_after = false;
L->n_drops = L->n_blips = L->n_ok = L->n_miss = 0;
}
void learn_pulse(learn_t *L, uint32_t t)
{
uint32_t iv = L->npulses ? t - L->last_pulse : 0;
L->last_pulse = t;
L->npulses++;
if (L->now < t) L->now = t;
if (L->phase != LEARN_PH_BLIP && !L->act_cand_set) {
L->t_act_cand = t; /* first echoed pulse */
L->act_cand_set = true;
}
if (!iv) return;
bool fast = is_fast(L, iv);
if (L->mode != LEARN_MODE_TRACK && fast) {
/* the faucet sped up when we echoed: its state is observable */
L->mode = LEARN_MODE_TRACK;
L->s.observable = 1;
L->p_act = (uint16_t)iv;
L->t_act = L->act_cand_set ? L->t_act_cand : t - iv;
L->events |= LEARN_EV_MODE;
dirty(L);
}
if (L->mode == LEARN_MODE_TRACK) {
if (fast) {
int32_t d = (int32_t)iv - (int32_t)L->p_act;
L->p_act = (uint16_t)((int32_t)L->p_act + d / 8);
if (L->p_act < 5) L->p_act = 5;
L->last_fast = t;
} else if (iv < 3u * (uint32_t)L->p_idle && iv > thr_mid(L)) {
int32_t d = (int32_t)iv - (int32_t)L->p_idle;
L->p_idle = (uint16_t)((int32_t)L->p_idle + d / 8);
}
}
L->last_iv = iv;
if (fast && L->phase == LEARN_PH_VERIFY) L->seen_active_after = true;
set_active(L, fast);
}
void learn_tick(learn_t *L, uint32_t now)
{
L->now = now;
/* idle-rate detection between pulses: a gap longer than the midpoint, and
* either clearly more than 2.5 active periods or the previous interval was
* already slow (so one lost pulse is not mistaken for a drop). */
if (L->mode == LEARN_MODE_TRACK && L->faucet_active) {
uint32_t gap = now - L->last_pulse, th = thr_mid(L);
if (gap > th && (gap * 2u > 5u * (uint32_t)L->p_act || L->last_iv > th))
set_active(L, false);
}
switch (L->mode) {
case LEARN_MODE_CLASSIFY:
if (now >= L->t_classify_end) {
L->mode = LEARN_MODE_FALLBACK;
L->s.observable = 2; /* t_arm stays at the echo start */
L->events |= LEARN_EV_MODE;
dirty(L);
}
return;
case LEARN_MODE_FALLBACK:
if (L->phase == LEARN_PH_BLIP) {
if (now >= L->t_phase_end) {
L->phase = LEARN_PH_ECHO;
L->t_arm = now;
L->events |= LEARN_EV_BLIP_END;
}
} else if (now >= L->t_arm + LEARN_FALLBACK_REARM_MS) {
uint32_t b = 2u * (uint32_t)L->p_idle + 50u;
start_blip(L, (uint16_t)(b > L->s.blip_ms ? clamp16(b, 0, LEARN_BLIP_MAX_MS) : L->s.blip_ms), false);
}
return;
case LEARN_MODE_TRACK:
break;
}
switch (L->phase) {
case LEARN_PH_ECHO:
if (!L->s.timeout_ms) {
if (now - L->t_act > LEARN_NO_TIMEOUT_MS) { /* never timed out: assume 60 s */
L->s.timeout_ms = LEARN_TIMEOUT_ASSUME_MS;
dirty(L);
}
} else if (L->faucet_active) {
uint32_t rearm = L->t_act + L->s.timeout_ms;
rearm = (rearm > L->t_act + L->s.margin_ms + 1000u) ? rearm - L->s.margin_ms : L->t_act + 1000u;
if (now >= rearm) start_blip(L, L->s.blip_ms, false);
}
break;
case LEARN_PH_BLIP:
if (now >= L->t_phase_end) {
uint32_t ret = (uint32_t)L->p_idle + 2u * L->p_act;
L->phase = LEARN_PH_VERIFY;
L->events |= LEARN_EV_BLIP_END;
L->act_cand_set = false;
L->seen_active_after = false; /* needs a fast interval after the blip */
L->t_return_by = now + (ret > LEARN_RETURN_MS ? ret : LEARN_RETURN_MS);
L->t_verify_end = now + 1500u;
if (!L->recovering && L->s.timeout_ms) {
uint32_t old_to = L->t_act + L->s.timeout_ms + 1000u + thr_mid(L); /* + drop detection latency */
if (old_to > L->t_verify_end) L->t_verify_end = old_to;
}
if (L->t_verify_end < L->t_return_by) L->t_verify_end = L->t_return_by;
}
break;
case LEARN_PH_VERIFY:
if (!L->seen_active_after) {
if (now > L->t_return_by) {
/* dropped out and did not come back: blip too short (it never saw
* us leave, typical when it had already timed out) */
if (L->recovering) L->recover_ms = clamp16(((uint32_t)L->blip_cur * 3u) / 2u, 0, LEARN_RECOVER_MAX_MS);
else blip_miss(L);
L->n_drops++;
L->events |= LEARN_EV_DROP;
start_recovery(L);
}
} else if (now >= L->t_verify_end && L->faucet_active) {
L->t_act = L->act_cand_set ? L->t_act_cand : now;
if (!L->recovering) blip_ok(L);
L->recovering = false;
L->phase = LEARN_PH_ECHO;
}
break;
}
}
bool learn_echo_on(const learn_t *L) { return L->phase != LEARN_PH_BLIP; }
uint8_t learn_take_events(learn_t *L)
{
uint8_t e = L->events;
L->events = 0;
return e;
}