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.
795559a
1d ago
Ghost Hand firmware
Firmware for Ghost Hand rev 2: an ATtiny1616 that keeps an automatic (infrared sensor) faucet running while someone stands at the sink. It hears the faucet's own IR interrogation pulse and fires an IR echo back inside that same pulse, so the faucet sees a hand. The echo path is pure hardware (analog comparator, event system, two timers); the CPU sleeps between pulses and only decides when to echo.
Safety first. Ghost Hand is for someone standing at the sink. It never runs unattended:
- a hard cap of 10 minutes per session, after which it must be picked up and set down again;
- it stops the moment it is picked up or tipped (0.25 g change or more than 30 degrees of tilt);
- it stops, and refuses to start, below 3.45 V of battery under load;
- it will not run while USB is plugged in, and it will not run at all if the accelerometer is missing;
- a 1 s watchdog resets the chip if the main loop ever stalls, and a reset drops the emitter gates to their pull-downs and unpowers the receiver.
Nothing beyond what an ordinary manual tap already allows.
Layout
| Path | What |
|---|---|
src/board.h |
pin map, thresholds and timing constants |
src/main.c |
state machine, safety checks |
src/echo.c/.h |
AC0 + EVSYS + TCB0/TCB1 transponder, blanking, duty guard, listen capture |
src/learn.c/.h |
faucet timeout and blip estimator (pure C, host testable) |
src/accel.c/.h |
LIS2DW12 driver over TWI0, tilt and pick-up maths |
src/leds.c/.h |
WS2812B-2020 bit-bang driver and light patterns |
src/power.c/.h |
clocks, RTC time base, sleep, pins, battery, ADC, watchdog, EEPROM |
test/test_learn.c |
simulated faucets that exercise learn.c (plain gcc) |
Makefile |
build, size, flash, fuses |
State machine
stateDiagram-v2
[*] --> ShowCharge: power-up
ShowCharge --> Sleep: 2 s
Sleep --> Settle: motion (accel INT1, PA2)
Sleep --> Charging: USB plugged (VBUS_SENSE, PB2)
Settle --> Sleep: no set-down within 15 s
Settle --> Charging: USB plugged
Settle --> Low: still and level 1.5 s, battery < 3.45 V
Settle --> Gauge: still and level 1.5 s (and picked up since the last Done/Low)
state Session {
Gauge --> Listen: receiver settled, threshold set
Listen --> Armed: 2 s (up to 5 s), period and width measured
Armed --> Armed: re-arm blips (learn.c)
}
Session --> Sleep: picked up or tipped (lights off at once)
Session --> Done: 10 min cap
Session --> Low: battery < 3.45 V twice
Session --> Charging: USB plugged
Done --> Sleep: 3 s fade
Low --> Sleep: 2 s red
Charging --> Unplugged: USB removed
Unplugged --> Sleep: 2 s showing the charge
The gauge (3 s fill) and the listen phase overlap: the receiver powers up while the gauge fills, so the echo is armed about 2 s after set-down. The STATUS dot then breathes while armed.
How the learning works
Faucets stop after a safety timeout (10 to 60 s typical) even with a hand in front of them, then ignore that "still object" until it goes away and comes back. Ghost Hand makes the hand go away for a moment (a blip: echo off) just before the timeout, so the faucet starts a fresh run and the water never stops. The blip must be longer than the faucet's release threshold (a few missed interrogations) and shorter than its run-on delay (how long the water keeps running after the hand leaves).
The only thing Ghost Hand can observe is the faucet's interrogation rate. Most faucets pulse faster while they see a user and slower when idle or timed out, and that is the window into the faucet:
- Listen (echo off, 2 s): the idle period (median interval) and the pulse width (TCB0 pulse-width capture) are measured. Echo width = pulse width + 5 us, clamped to 8..40 us.
- Classify (echo on): if an interval below 60 % of the idle period appears, the rate is observable (TRACK). If not within max(3 s, 2 idle periods + 1 s), the faucet is FALLBACK.
- Learn the timeout (TRACK, first time): echo until the rate falls back to idle. Timeout = time of the last fast pulse minus the activation (the first echoed pulse). A recovery blip, sized from the idle period because a timed-out faucet only releases after missed idle polls, restarts the faucet.
- Re-arm proactively at activation + timeout - margin (margin starts at 1.5 s): blip, then verify.
The faucet must be back at the active rate within max(1 s, idle + 2 active periods) 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).
- stayed on: next blip 15 % shorter (minimum 120 ms, never below the floor);
- dropped: next blip 30 % longer, the floor becomes max(1.15 x the missed blip, last good blip + 1.1 active periods), the margin grows 0.5 s (re-arm earlier, up to 4 s), and the drop is a new timeout observation.
- The floor is the trick. The learner only probes below the last good blip until its first miss; after that it never goes below the floor again. "Last good blip + one active period" means the blip always covers one more interrogation than a blip that already worked, whatever the pulse phase, so once learnt the water does not drop out.
- Timeout estimate: an EWMA that rises slowly (alpha 1/4) and falls at once, because over-estimating costs a water drop while under-estimating only blips a little early.
- No observable rate change (FALLBACK): blip every 8 s (shorter than the shortest expected timeout) with max(current blip, 2 idle periods + 50 ms), so at least two interrogations are missed.
- Persistence: timeout, blip, floor, margin, rate class and the faucet's idle and active periods are saved to EEPROM (CRC-8, at most every 30 s and at the end of a session). The idle period is a fingerprint: a faucet whose idle period differs by more than 30 % starts from the defaults. Fuse EESAVE keeps the learnt faucet across a reflash.
- Looking like a moving hand: some sensors ignore a perfectly still reflection, so the emitters rotate every 300 ms between both, 850 nm only and 940 nm only (three different echo amplitudes).
Test results (make test)
The simulated faucet has the states IDLE, CONFIRM (two hits needed), ACTIVE (timer), RUN-ON and LOCKOUT, release after N missed polls, +/-3 % poll jitter; pulses reach the learner on the 16 ms main loop tick like on the device. Six 10-minute sessions per faucet, store carried between sessions like the EEPROM.
| Faucet (timeout, idle / active poll, release) | Mode | Learnt timeout | Final blip (release threshold) | Drops: session 1 / after learning |
|---|---|---|---|---|
| 10 s, 500 / 100 ms, 2 polls | TRACK | 10.05 s | 302 ms (200 ms) | 2 / 0 |
| 30 s, 800 / 80 ms, 3 polls | TRACK | 30.06 s | 311 ms (240 ms) | 2 / 0 |
| 55 s, 1500 / 125 ms, 2 polls | TRACK | 55.10 s | 363 ms (250 ms) | 2 / 0 |
| 60 s, 2000 / 50 ms, 4 polls | TRACK | 60.01 s | 215 ms (200 ms) | 1 (+1 in session 2) / 0 |
| 30 s, run-on polled at the idle rate | TRACK | 30.06 s | 274 ms (200 ms) | 2 / 0 |
| 30 s, no rate change (250 ms) | FALLBACK | n/a | 600 ms | 0 / 0 |
| 10 s, no rate change (300 ms) | FALLBACK | n/a | 650 ms | 0 / 0 |
| learnt on a 55 s faucet, moved to a 10 s one | TRACK | 55.00 s then 10.13 s | 332 ms | 1 / 0 |
36 checks, 0 failed. The drops in the first session are the learning itself: one at the faucet's real timeout (that is how it is measured) and one when the blip is first shortened past the release threshold. Sessions 2 to 6 on the same faucet run the full 10 minutes with the water on throughout (one exception: the 60 s faucet needs its 10 re-arms per session to reach the floor, so its floor-finding miss lands in session 2). The 60 s / 2 s faucet's first session shows 24 s of water off: recovering from a timeout takes 4 missed 2 s polls, so the recovery blip grows to 8 s.
Limits of the model: a faucet with no run-on (water stops the instant the hand leaves) will flicker at each blip; a faucet that does not restart its timer when the hand returns during run-on can only be re-armed by a blip longer than its run-on; for FALLBACK faucets the blip cannot be verified.
Timing table
| Item | Value | Where |
|---|---|---|
| Session cap | 600 s | SESSION_CAP_MS |
| Still and level before start | 1.5 s, within 60 mg, tilt < 30 deg | SETTLE_STILL_MS |
| Gauge shown at start | 3 s | GAUGE_SHOW_MS |
| Receiver settle | 60 ms (RX_REF: 1 uF, ~9 ms tau) | RX_SETTLE_MS |
| Listen, echo disabled | 2 s (extends to 5 s for < 3 pulses) | LISTEN_MS |
| Echo width | faucet pulse + 5 us, 8..40 us | ECHO_EXTRA_US |
| Echo latency | comparator + event path, < 1 us (TCB ASYNC output) | echo.c |
| Blanking after each echo | 150 us | ECHO_BLANK_US |
| Echo duty guard | <= 200 us per 10 ms (2 %) | ECHO_DUTY_MAX_US |
| Burst grouping | pulses < 3 ms apart = one interrogation | PULSE_GROUP_MS |
| Emitter rotation | 300 ms | EMITTER_ROTATE_MS |
| First blip | 600 ms, x0.85 on success, x1.3 on a miss, 120 ms min | learn.h |
| Re-arm margin | 1.5 s, +0.5 s per miss, 4 s max | learn.h |
| Fallback re-arm | every 8 s | LEARN_FALLBACK_REARM_MS |
| Pick-up / tilt | 0.25 g / 30 deg vs the set-down vector, checked at 16 Hz + INT1 | board.h |
| Battery check in session | every 10 s, two low readings stop | BATT_CHECK_MS |
| Main tick | 64 Hz (RTC PIT), lights at 32 Hz | power.c |
| Done / low / unplugged shows | 3 s / 2 s / 2 s | main.c |
| Full charge | four green 5 s, then a dim pulse every 10 s | leds.c |
| Charge (USB) | 77 mA (Rprog 13k, 0.5 C), about 2.4 h from empty | hardware (MCP73831) |
Pin table
| VQFN-20 pin | Port | Net | Use |
|---|---|---|---|
| 19 | PA0 | UPDI | programming |
| 20 | PA1 | CHG_STAT | input; low = charging, high = complete (valid only with VBUS) |
| 1 | PA2 | ACC_INT1 | input, rising edge; fully async pin, wakes power-down |
| 2 | PA3 | IR940_G | TCB1 WO (default PORTMUX); input when that emitter is off |
| 5 | PA4 | RX_PWR | output, high = TLV9062 and RX_REF on |
| 6 | PA5 | IR850_G | TCB0 WO (default PORTMUX); input when that emitter is off |
| 7 | PA6 | VBAT_SENSE | ADC0 AIN6 (2.5 V ref), VBAT/2 from an always-on 4.7M/4.7M divider, digital input disabled |
| 8 | PA7 | RX_SIG | AC0 AINP0, digital input disabled |
| 14 | PB0 | SCL | TWI0 (default pins), 100 kHz |
| 13 | PB1 | SDA | TWI0 |
| 12 | PB2 | VBUS_SENSE | input, both edges; fully async pin, wakes power-down |
| 11 | PB3 | (none) | input with pull-up (no longer connected) |
| 10 | PB4 | LED_EN_N | output, low = WS2812 rail on |
| 9 | PB5 | LED_DIN | output, bit-banged; low whenever the rail is off |
| 15 | PC0 | ACC_INT2 | input, unused |
| 16 | PC1 | (none) | input with pull-up |
| 17 | PC2 | ECHO_EVT | EVOUT2 = AC0 event during a session (probe point) |
| 18 | PC3 | (none) | input with pull-up |
Power states
Currents are the typical values of the design's battery-life calculation (design/calcs.json, 25 C,
Adafruit 1317 150 mAh pack, 128 mAh usable, stop at 3.45 V).
| State | MCU | Peripherals on | Current |
|---|---|---|---|
| Sleep | power-down, PIT off, wake on PA2 / PB2 edges | accel LP mode 1 12.5 Hz wake-up, everything else off | 4.7 uA total (MCU 0.1, accel 1.0, LDO 0.025, charger 0.15, VBAT divider 9.4 Mohm 0.42, pack protection 3.0) |
| Settle / charging display | standby between 64 Hz ticks | accel 50 Hz, LEDs | about 1 mA plus the lights |
| Session | standby with OSC20M kept on by the TCBs, IDLE only during the 0.2 ms blanking | receiver, AC0, DAC0, TCB0/1, accel 50 Hz | 2.93 mA average (MCU 0.8, receiver 1.08, bias 0.03, echo pulses 0.41 from the 850 nm emitter at 8.2 ohm and the 940 nm one at 4.7 ohm, status dot 0.6, accel 0.004) |
| Charging | standby, lights only | MCP73831 at 77 mA from USB-C | the gauge shows the level |
That gives about 0.49 mAh per 10-minute session, about 261 sessions per charge: 40 days at six sessions a day, 74 at three, 170 at one, so charging once a month holds up to about 8.2 sessions a day. Pure sleep alone would last 3.1 years; the cell's self-discharge (about 3 % a month) is larger than the sleep current. Charging takes about 2.4 h at 77 mA (0.5 C).
LED behaviour
Rev 2 has no logo light. The WS2812B-2020 chain is D4 (the STATUS dot, LED 0) and then D5 to D8 (the charge gauge 25 / 50 / 75 / 100 %, LEDs 1 to 4). Every light is a 1.5 mm round clear-resin pipe sitting on its LED, with an opaque wall between pipes; the silkscreen and the case engraving name each one. The STATUS dot shows everything rev 1's logo showed.
| State | STATUS dot (D4) | Gauge (D5 to D8) |
|---|---|---|
| Session start | wakes up dim | fills to the battery level over 1.5 s, held to 3 s (amber at 25 % or less) |
| Armed | soft cyan-white breathing, 4 s period | off |
| Echoing (faucet active) | the breath 50 % brighter | off |
| Re-arm blip | quick flicker | off |
| Done (10 min) | slow warm fade, 2.5 s | off |
| Low battery | red blink | first segment red |
| Charging | off | filled to the level, next segment pulses |
| Full | off | all four green 5 s, then a dim pulse every 10 s |
| Unplugged | off | the level for 2 s |
Sized for a 1.5 mm dot: a dot right on its LED looks several times brighter than rev 1's spread-out logo at the same drive, so the levels are about half of rev 1's (status breath 2 to 12 of 255 instead of 3 to 24, gauge 28 instead of 40, red 24 instead of 40). Total brightness is capped (sum of all 15 channel values at most 640, about 30 mA at 12 mA per channel), which keeps the 150 mAh cell's peak load at 0.2 C and the 3.3 V LDO far under 60 mA with the MCU and receiver.
The WS2812 data pin (PB5) has no SPI, USART or CCL output on this chip, so the driver is a cycle-counted loop. At 5 MHz a 1.25 us bit is only 6 cycles, and 20 MHz needs 4.5 V, so the clock is raised to 10 MHz (OSC20M / 2, inside the 0 to 10 MHz at 2.7 V speed grade) for the ~200 us write only, with interrupts off and the echo event users disconnected so the timers never run on the faster clock. Timing at 10 MHz: T0H 300 ns, T1H 700 ns, T1L 600 ns, T0L 1000 ns, 1.3 us per bit.
Build
make # build/ghost-hand.hex, prints the size
make test # learner tests with plain gcc
Measured with avr-gcc 7.3.0 (Microchip AVR 8-bit toolchain 3.7.0) and the Microchip ATtiny DFP
3.1.260 device pack, -Os -flto:
text data bss dec hex filename
13122 2 366 13490 34b2 build/ghost-hand.elf
13.1 KB of 16 KB flash, 368 B of 2 KB SRAM, 20 B of EEPROM.
Getting a toolchain without root: the Makefile uses .toolchain/ if present, else avr-gcc on PATH.
To populate .toolchain/ (ignored by git), download and unpack into it:
- the Microchip "AVR 8-bit Toolchain (Linux 64-bit)" tarball (avr8-gnu-toolchain-3.7.0.1796-linux),
- the "Microchip ATtiny Series Device Support" pack (Microchip.ATtiny_DFP.3.1.260.atpack, a zip) into
.toolchain/dfp/; it providesiotn1616.hand the attiny1616 device specs that the 3.7.0 toolchain lacks.
A distribution avr-gcc with avr-libc 2.1 or newer also works without the pack.
Flashing over UPDI
The board exposes UPDI, 3V3 and GND on machine contacts MC4 (UPDI), MC3 (3V3) and MC1 (GND). Use a serial UPDI adapter (a USB-serial adapter with a 4.7k resistor or a Schottky diode between TX and RX, RX wired to UPDI), at 3.3 V logic:
- Connect adapter GND to MC1 and UPDI to MC4. Power the board from its battery, or feed 3.3 V into MC3 with no battery connected (never both).
- Fuses once:
make fuses PORT=/dev/ttyUSB0(ormake fuses-avrdude). - Firmware:
make flash PORT=/dev/ttyUSB0(pymcuprog) ormake flash-avrdude PORT=/dev/ttyUSB0.
| Fuse | Value | Meaning |
|---|---|---|
| WDTCFG | 0x00 | watchdog off at reset, the firmware runs it at 1 s while awake |
| BODCFG | 0x44 | BOD at 2.6 V, enabled while active, off in sleep |
| OSCCFG | 0x02 | 20 MHz oscillator (divided to 5 MHz, 10 MHz for LED writes) |
| SYSCFG0 | 0xC5 | UPDI pin, no CRC, EESAVE (keeps the learnt faucet when reflashing) |
| SYSCFG1 | 0x07 | 64 ms start-up |
| APPEND / BOOTEND | 0x00 | no boot section |
Battery measurement
VBAT_SENSE (PA6) is VBAT / 2 from an always-on 4.7M / 4.7M divider with 100 nF (C6) on the tap, so it
never exceeds 2.1 V. The 2.35 Mohm source cannot charge the ADC sample capacitor itself; C6 holds the
node and the sample takes a negligible charge from it. vbat_mv() uses the 2.5 V reference, the
longest sample time (SAMPCTRL = 31), 16 accumulated conversions and the average of 4 such reads
(4.88 mV per LSB at VBAT). No enable pin and no settling wait are needed. Pin leakage through
2.35 Mohm is a few mV at typical leakage; check one board against a meter.
Datasheet facts relied on (ATtiny1614/16/17, DS40002204A)
- I/O multiplexing: TCB0 WO = PA5 and TCB1 WO = PA3 by default (alternates PC0 / PC4); AC0 AINP0 = PA7;
ADC0 AIN6 = PA6; TWI0 SCL/SDA = PB0/PB1; EVOUT2 = PC2. PA5 is also AC0 OUT and PA6 DAC0 OUT, so
AC0.CTRLA.OUTENandDAC0.CTRLA.OUTENstay 0. - Pins Px2 and Px6 have full asynchronous pin sensing; PA2 and PB2 can wake power-down on an edge.
- The 1-series AC negative input is
AC_MUXNEG_DAC_gc(DAC0, reference fromVREF.CTRLA.DAC0REFSEL); "DACREF" is the 2-series / AVR Dx name. - EVSYS:
ASYNCCH0 = AC0_OUT; usersASYNCUSER0= TCB0,ASYNCUSER11= TCB1,ASYNCUSER10= EVOUT2. - TCB single-shot:
CTRLB.ASYNC = 1sets the output on the event itself; events are ignored while counting; write TOP to CNT before enabling; EDGE = 0 starts on the rising edge; CAPT at TOP. - TCB pulse-width mode (EDGE = 0): rising edge restarts, falling edge captures into CCMP.
- Sleep mode activity: in standby only RTC, ADC and TCB (RUNSTDBY) are clocked and the wake sources are PORT, TWI address match, USART start of frame, ADC, RTC and TCB capture. The AC interrupt does not wake standby and TCA0 has no RUNSTDBY: hence the TCB0 CAPT interrupt for timestamps and IDLE during blanking.
- Speed grades: 0 to 5 MHz at 1.8 V, 0 to 10 MHz at 2.7 V, 0 to 20 MHz at 4.5 V.
- Fuses: SYSCFG0 (CRCSRC, RSTPINCFG, EESAVE), BODCFG (LVL, SAMPFREQ, ACTIVE, SLEEP).
- LIS2DW12: WHO_AM_I 0x44; CTRL1 ODR/MODE/LP_MODE; LP mode 1 is 12-bit; WAKE_UP_THS LSB = FS/64; LIR latched interrupts; CTRL7 INTERRUPTS_ENABLE.
- WS2812B-2020-V6: T0H 220 to 380 ns, T1H/T0L/T1L 580 ns to 1 us, bit at least 1.25 us, reset 280 us, 12 mA per channel.
# Ghost Hand firmware
Firmware for Ghost Hand rev 2: an ATtiny1616 that keeps an automatic (infrared sensor) faucet running
while someone stands at the sink. It hears the faucet's own IR interrogation pulse and fires an IR echo
back inside that same pulse, so the faucet sees a hand. The echo path is pure hardware
(analog comparator, event system, two timers); the CPU sleeps between pulses and only decides *when*
to echo.
**Safety first.** Ghost Hand is for someone standing at the sink. It never runs unattended:
* a hard cap of 10 minutes per session, after which it must be picked up and set down again;
* it stops the moment it is picked up or tipped (0.25 g change or more than 30 degrees of tilt);
* it stops, and refuses to start, below 3.45 V of battery under load;
* it will not run while USB is plugged in, and it will not run at all if the accelerometer is missing;
* a 1 s watchdog resets the chip if the main loop ever stalls, and a reset drops the emitter gates to
their pull-downs and unpowers the receiver.
Nothing beyond what an ordinary manual tap already allows.
## Layout
| Path | What |
|---|---|
| `src/board.h` | pin map, thresholds and timing constants |
| `src/main.c` | state machine, safety checks |
| `src/echo.c/.h` | AC0 + EVSYS + TCB0/TCB1 transponder, blanking, duty guard, listen capture |
| `src/learn.c/.h` | faucet timeout and blip estimator (pure C, host testable) |
| `src/accel.c/.h` | LIS2DW12 driver over TWI0, tilt and pick-up maths |
| `src/leds.c/.h` | WS2812B-2020 bit-bang driver and light patterns |
| `src/power.c/.h` | clocks, RTC time base, sleep, pins, battery, ADC, watchdog, EEPROM |
| `test/test_learn.c` | simulated faucets that exercise `learn.c` (plain gcc) |
| `Makefile` | build, size, flash, fuses |
## State machine
```mermaid
stateDiagram-v2
[*] --> ShowCharge: power-up
ShowCharge --> Sleep: 2 s
Sleep --> Settle: motion (accel INT1, PA2)
Sleep --> Charging: USB plugged (VBUS_SENSE, PB2)
Settle --> Sleep: no set-down within 15 s
Settle --> Charging: USB plugged
Settle --> Low: still and level 1.5 s, battery < 3.45 V
Settle --> Gauge: still and level 1.5 s (and picked up since the last Done/Low)
state Session {
Gauge --> Listen: receiver settled, threshold set
Listen --> Armed: 2 s (up to 5 s), period and width measured
Armed --> Armed: re-arm blips (learn.c)
}
Session --> Sleep: picked up or tipped (lights off at once)
Session --> Done: 10 min cap
Session --> Low: battery < 3.45 V twice
Session --> Charging: USB plugged
Done --> Sleep: 3 s fade
Low --> Sleep: 2 s red
Charging --> Unplugged: USB removed
Unplugged --> Sleep: 2 s showing the charge
```
The gauge (3 s fill) and the listen phase overlap: the receiver powers up while the gauge fills, so the
echo is armed about 2 s after set-down. The STATUS dot then breathes while armed.
## How the learning works
Faucets stop after a safety timeout (10 to 60 s typical) even with a hand in front of them, then ignore
that "still object" until it goes away and comes back. Ghost Hand makes the hand go away for a moment
(a *blip*: echo off) just before the timeout, so the faucet starts a fresh run and the water never
stops. The blip must be longer than the faucet's release threshold (a few missed interrogations) and
shorter than its run-on delay (how long the water keeps running after the hand leaves).
The only thing Ghost Hand can observe is the faucet's interrogation rate. Most faucets pulse faster
while they see a user and slower when idle or timed out, and that is the window into the faucet:
1. **Listen** (echo off, 2 s): the idle period (median interval) and the pulse width (TCB0 pulse-width
capture) are measured. Echo width = pulse width + 5 us, clamped to 8..40 us.
2. **Classify** (echo on): if an interval below 60 % of the idle period appears, the rate is observable
(TRACK). If not within max(3 s, 2 idle periods + 1 s), the faucet is FALLBACK.
3. **Learn the timeout** (TRACK, first time): echo until the rate falls back to idle. Timeout = time of
the last fast pulse minus the activation (the first echoed pulse). A recovery blip, sized from the
idle period because a timed-out faucet only releases after missed *idle* polls, restarts the faucet.
4. **Re-arm proactively** at activation + timeout - margin (margin starts at 1.5 s): blip, then verify.
The faucet must be back at the active rate within max(1 s, idle + 2 active periods) 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).
* stayed on: next blip 15 % shorter (minimum 120 ms, never below the floor);
* dropped: next blip 30 % longer, the floor becomes max(1.15 x the missed blip, last good blip +
1.1 active periods), the margin grows 0.5 s (re-arm earlier, up to 4 s), and the drop is a new
timeout observation.
5. **The floor is the trick.** The learner only probes below the last good blip until its first miss;
after that it never goes below the floor again. "Last good blip + one active period" means the blip
always covers one more interrogation than a blip that already worked, whatever the pulse phase, so
once learnt the water does not drop out.
6. **Timeout estimate:** an EWMA that rises slowly (alpha 1/4) and falls at once, because over-estimating
costs a water drop while under-estimating only blips a little early.
7. **No observable rate change** (FALLBACK): blip every 8 s (shorter than the shortest expected timeout)
with max(current blip, 2 idle periods + 50 ms), so at least two interrogations are missed.
8. **Persistence:** timeout, blip, floor, margin, rate class and the faucet's idle and active periods are
saved to EEPROM (CRC-8, at most every 30 s and at the end of a session). The idle period is a
fingerprint: a faucet whose idle period differs by more than 30 % starts from the defaults. Fuse
EESAVE keeps the learnt faucet across a reflash.
9. **Looking like a moving hand:** some sensors ignore a perfectly still reflection, so the emitters
rotate every 300 ms between both, 850 nm only and 940 nm only (three different echo amplitudes).
### Test results (`make test`)
The simulated faucet has the states IDLE, CONFIRM (two hits needed), ACTIVE (timer), RUN-ON and
LOCKOUT, release after N missed polls, +/-3 % poll jitter; pulses reach the learner on the 16 ms main
loop tick like on the device. Six 10-minute sessions per faucet, store carried between sessions like
the EEPROM.
| Faucet (timeout, idle / active poll, release) | Mode | Learnt timeout | Final blip (release threshold) | Drops: session 1 / after learning |
|---|---|---|---|---|
| 10 s, 500 / 100 ms, 2 polls | TRACK | 10.05 s | 302 ms (200 ms) | 2 / 0 |
| 30 s, 800 / 80 ms, 3 polls | TRACK | 30.06 s | 311 ms (240 ms) | 2 / 0 |
| 55 s, 1500 / 125 ms, 2 polls | TRACK | 55.10 s | 363 ms (250 ms) | 2 / 0 |
| 60 s, 2000 / 50 ms, 4 polls | TRACK | 60.01 s | 215 ms (200 ms) | 1 (+1 in session 2) / 0 |
| 30 s, run-on polled at the idle rate | TRACK | 30.06 s | 274 ms (200 ms) | 2 / 0 |
| 30 s, no rate change (250 ms) | FALLBACK | n/a | 600 ms | 0 / 0 |
| 10 s, no rate change (300 ms) | FALLBACK | n/a | 650 ms | 0 / 0 |
| learnt on a 55 s faucet, moved to a 10 s one | TRACK | 55.00 s then 10.13 s | 332 ms | 1 / 0 |
36 checks, 0 failed. The drops in the first session are the learning itself: one at the faucet's real
timeout (that is how it is measured) and one when the blip is first shortened past the release threshold.
Sessions 2 to 6 on the same faucet run the full 10 minutes with the water on throughout (one exception:
the 60 s faucet needs its 10 re-arms per session to reach the floor, so its floor-finding miss lands in
session 2). The 60 s / 2 s faucet's first session shows 24 s of water off: recovering from a timeout
takes 4 missed 2 s polls, so the recovery blip grows to 8 s.
Limits of the model: a faucet with no run-on (water stops the instant the hand leaves) will flicker at
each blip; a faucet that does not restart its timer when the hand returns during run-on can only be
re-armed by a blip longer than its run-on; for FALLBACK faucets the blip cannot be verified.
## Timing table
| Item | Value | Where |
|---|---|---|
| Session cap | 600 s | `SESSION_CAP_MS` |
| Still and level before start | 1.5 s, within 60 mg, tilt < 30 deg | `SETTLE_STILL_MS` |
| Gauge shown at start | 3 s | `GAUGE_SHOW_MS` |
| Receiver settle | 60 ms (RX_REF: 1 uF, ~9 ms tau) | `RX_SETTLE_MS` |
| Listen, echo disabled | 2 s (extends to 5 s for < 3 pulses) | `LISTEN_MS` |
| Echo width | faucet pulse + 5 us, 8..40 us | `ECHO_EXTRA_US` |
| Echo latency | comparator + event path, < 1 us (TCB ASYNC output) | `echo.c` |
| Blanking after each echo | 150 us | `ECHO_BLANK_US` |
| Echo duty guard | <= 200 us per 10 ms (2 %) | `ECHO_DUTY_MAX_US` |
| Burst grouping | pulses < 3 ms apart = one interrogation | `PULSE_GROUP_MS` |
| Emitter rotation | 300 ms | `EMITTER_ROTATE_MS` |
| First blip | 600 ms, x0.85 on success, x1.3 on a miss, 120 ms min | `learn.h` |
| Re-arm margin | 1.5 s, +0.5 s per miss, 4 s max | `learn.h` |
| Fallback re-arm | every 8 s | `LEARN_FALLBACK_REARM_MS` |
| Pick-up / tilt | 0.25 g / 30 deg vs the set-down vector, checked at 16 Hz + INT1 | `board.h` |
| Battery check in session | every 10 s, two low readings stop | `BATT_CHECK_MS` |
| Main tick | 64 Hz (RTC PIT), lights at 32 Hz | `power.c` |
| Done / low / unplugged shows | 3 s / 2 s / 2 s | `main.c` |
| Full charge | four green 5 s, then a dim pulse every 10 s | `leds.c` |
| Charge (USB) | 77 mA (Rprog 13k, 0.5 C), about 2.4 h from empty | hardware (MCP73831) |
## Pin table
| VQFN-20 pin | Port | Net | Use |
|---|---|---|---|
| 19 | PA0 | UPDI | programming |
| 20 | PA1 | CHG_STAT | input; low = charging, high = complete (valid only with VBUS) |
| 1 | PA2 | ACC_INT1 | input, rising edge; fully async pin, wakes power-down |
| 2 | PA3 | IR940_G | TCB1 WO (default PORTMUX); input when that emitter is off |
| 5 | PA4 | RX_PWR | output, high = TLV9062 and RX_REF on |
| 6 | PA5 | IR850_G | TCB0 WO (default PORTMUX); input when that emitter is off |
| 7 | PA6 | VBAT_SENSE | ADC0 AIN6 (2.5 V ref), VBAT/2 from an always-on 4.7M/4.7M divider, digital input disabled |
| 8 | PA7 | RX_SIG | AC0 AINP0, digital input disabled |
| 14 | PB0 | SCL | TWI0 (default pins), 100 kHz |
| 13 | PB1 | SDA | TWI0 |
| 12 | PB2 | VBUS_SENSE | input, both edges; fully async pin, wakes power-down |
| 11 | PB3 | (none) | input with pull-up (no longer connected) |
| 10 | PB4 | LED_EN_N | output, low = WS2812 rail on |
| 9 | PB5 | LED_DIN | output, bit-banged; low whenever the rail is off |
| 15 | PC0 | ACC_INT2 | input, unused |
| 16 | PC1 | (none) | input with pull-up |
| 17 | PC2 | ECHO_EVT | EVOUT2 = AC0 event during a session (probe point) |
| 18 | PC3 | (none) | input with pull-up |
## Power states
Currents are the typical values of the design's battery-life calculation (`design/calcs.json`, 25 C,
Adafruit 1317 150 mAh pack, 128 mAh usable, stop at 3.45 V).
| State | MCU | Peripherals on | Current |
|---|---|---|---|
| Sleep | power-down, PIT off, wake on PA2 / PB2 edges | accel LP mode 1 12.5 Hz wake-up, everything else off | 4.7 uA total (MCU 0.1, accel 1.0, LDO 0.025, charger 0.15, VBAT divider 9.4 Mohm 0.42, pack protection 3.0) |
| Settle / charging display | standby between 64 Hz ticks | accel 50 Hz, LEDs | about 1 mA plus the lights |
| Session | standby with OSC20M kept on by the TCBs, IDLE only during the 0.2 ms blanking | receiver, AC0, DAC0, TCB0/1, accel 50 Hz | 2.93 mA average (MCU 0.8, receiver 1.08, bias 0.03, echo pulses 0.41 from the 850 nm emitter at 8.2 ohm and the 940 nm one at 4.7 ohm, status dot 0.6, accel 0.004) |
| Charging | standby, lights only | MCP73831 at 77 mA from USB-C | the gauge shows the level |
That gives about 0.49 mAh per 10-minute session, about 261 sessions per charge: 40 days at six sessions
a day, 74 at three, 170 at one, so charging once a month holds up to about 8.2 sessions a day. Pure sleep
alone would last 3.1 years; the cell's self-discharge (about 3 % a month) is larger than the sleep
current. Charging takes about 2.4 h at 77 mA (0.5 C).
## LED behaviour
Rev 2 has no logo light. The WS2812B-2020 chain is D4 (the STATUS dot, LED 0) and then D5 to D8 (the
charge gauge 25 / 50 / 75 / 100 %, LEDs 1 to 4). Every light is a 1.5 mm round clear-resin pipe sitting
on its LED, with an opaque wall between pipes; the silkscreen and the case engraving name each one. The
STATUS dot shows everything rev 1's logo showed.
| State | STATUS dot (D4) | Gauge (D5 to D8) |
|---|---|---|
| Session start | wakes up dim | fills to the battery level over 1.5 s, held to 3 s (amber at 25 % or less) |
| Armed | soft cyan-white breathing, 4 s period | off |
| Echoing (faucet active) | the breath 50 % brighter | off |
| Re-arm blip | quick flicker | off |
| Done (10 min) | slow warm fade, 2.5 s | off |
| Low battery | red blink | first segment red |
| Charging | off | filled to the level, next segment pulses |
| Full | off | all four green 5 s, then a dim pulse every 10 s |
| Unplugged | off | the level for 2 s |
Sized for a 1.5 mm dot: a dot right on its LED looks several times brighter than rev 1's spread-out
logo at the same drive, so the levels are about half of rev 1's (status breath 2 to 12 of 255 instead
of 3 to 24, gauge 28 instead of 40, red 24 instead of 40). Total brightness is capped (sum of all 15
channel values at most 640, about 30 mA at 12 mA per channel), which keeps the 150 mAh cell's peak load
at 0.2 C and the 3.3 V LDO far under 60 mA with the MCU and receiver.
The WS2812 data pin (PB5) has no SPI, USART or CCL output on this chip, so the driver is a cycle-counted
loop. At 5 MHz a 1.25 us bit is only 6 cycles, and 20 MHz needs 4.5 V, so the clock is raised to
10 MHz (OSC20M / 2, inside the 0 to 10 MHz at 2.7 V speed grade) for the ~200 us write only, with
interrupts off and the echo event users disconnected so the timers never run on the faster clock.
Timing at 10 MHz: T0H 300 ns, T1H 700 ns, T1L 600 ns, T0L 1000 ns, 1.3 us per bit.
## Build
```
make # build/ghost-hand.hex, prints the size
make test # learner tests with plain gcc
```
Measured with avr-gcc 7.3.0 (Microchip AVR 8-bit toolchain 3.7.0) and the Microchip ATtiny DFP
3.1.260 device pack, `-Os -flto`:
```
text data bss dec hex filename
13122 2 366 13490 34b2 build/ghost-hand.elf
```
13.1 KB of 16 KB flash, 368 B of 2 KB SRAM, 20 B of EEPROM.
Getting a toolchain without root: the Makefile uses `.toolchain/` if present, else `avr-gcc` on PATH.
To populate `.toolchain/` (ignored by git), download and unpack into it:
* the Microchip "AVR 8-bit Toolchain (Linux 64-bit)" tarball (avr8-gnu-toolchain-3.7.0.1796-linux),
* the "Microchip ATtiny Series Device Support" pack (Microchip.ATtiny_DFP.3.1.260.atpack, a zip) into
`.toolchain/dfp/`; it provides `iotn1616.h` and the attiny1616 device specs that the 3.7.0 toolchain
lacks.
A distribution avr-gcc with avr-libc 2.1 or newer also works without the pack.
## Flashing over UPDI
The board exposes UPDI, 3V3 and GND on machine contacts **MC4 (UPDI)**, **MC3 (3V3)** and **MC1 (GND)**.
Use a serial UPDI adapter (a USB-serial adapter with a 4.7k resistor or a Schottky diode between TX and
RX, RX wired to UPDI), at 3.3 V logic:
1. Connect adapter GND to MC1 and UPDI to MC4. Power the board from its battery, or feed 3.3 V into MC3
with no battery connected (never both).
2. Fuses once: `make fuses PORT=/dev/ttyUSB0` (or `make fuses-avrdude`).
3. Firmware: `make flash PORT=/dev/ttyUSB0` (pymcuprog) or `make flash-avrdude PORT=/dev/ttyUSB0`.
| Fuse | Value | Meaning |
|---|---|---|
| WDTCFG | 0x00 | watchdog off at reset, the firmware runs it at 1 s while awake |
| BODCFG | 0x44 | BOD at 2.6 V, enabled while active, off in sleep |
| OSCCFG | 0x02 | 20 MHz oscillator (divided to 5 MHz, 10 MHz for LED writes) |
| SYSCFG0 | 0xC5 | UPDI pin, no CRC, EESAVE (keeps the learnt faucet when reflashing) |
| SYSCFG1 | 0x07 | 64 ms start-up |
| APPEND / BOOTEND | 0x00 | no boot section |
## Battery measurement
VBAT_SENSE (PA6) is VBAT / 2 from an always-on 4.7M / 4.7M divider with 100 nF (C6) on the tap, so it
never exceeds 2.1 V. The 2.35 Mohm source cannot charge the ADC sample capacitor itself; C6 holds the
node and the sample takes a negligible charge from it. `vbat_mv()` uses the 2.5 V reference, the
longest sample time (SAMPCTRL = 31), 16 accumulated conversions and the average of 4 such reads
(4.88 mV per LSB at VBAT). No enable pin and no settling wait are needed. Pin leakage through
2.35 Mohm is a few mV at typical leakage; check one board against a meter.
## Datasheet facts relied on (ATtiny1614/16/17, DS40002204A)
* I/O multiplexing: TCB0 WO = PA5 and TCB1 WO = PA3 by default (alternates PC0 / PC4); AC0 AINP0 = PA7;
ADC0 AIN6 = PA6; TWI0 SCL/SDA = PB0/PB1; EVOUT2 = PC2. PA5 is also AC0 OUT and PA6 DAC0 OUT, so
`AC0.CTRLA.OUTEN` and `DAC0.CTRLA.OUTEN` stay 0.
* Pins Px2 and Px6 have full asynchronous pin sensing; PA2 and PB2 can wake power-down on an edge.
* The 1-series AC negative input is `AC_MUXNEG_DAC_gc` (DAC0, reference from `VREF.CTRLA.DAC0REFSEL`);
"DACREF" is the 2-series / AVR Dx name.
* EVSYS: `ASYNCCH0 = AC0_OUT`; users `ASYNCUSER0` = TCB0, `ASYNCUSER11` = TCB1, `ASYNCUSER10` = EVOUT2.
* TCB single-shot: `CTRLB.ASYNC = 1` sets the output on the event itself; events are ignored while
counting; write TOP to CNT before enabling; EDGE = 0 starts on the rising edge; CAPT at TOP.
* TCB pulse-width mode (EDGE = 0): rising edge restarts, falling edge captures into CCMP.
* Sleep mode activity: in standby only RTC, ADC and TCB (RUNSTDBY) are clocked and the wake sources are
PORT, TWI address match, USART start of frame, ADC, RTC and TCB capture. The AC interrupt does not wake
standby and TCA0 has no RUNSTDBY: hence the TCB0 CAPT interrupt for timestamps and IDLE during
blanking.
* Speed grades: 0 to 5 MHz at 1.8 V, 0 to 10 MHz at 2.7 V, 0 to 20 MHz at 4.5 V.
* Fuses: SYSCFG0 (CRCSRC, RSTPINCFG, EESAVE), BODCFG (LVL, SAMPFREQ, ACTIVE, SLEEP).
* LIS2DW12: WHO_AM_I 0x44; CTRL1 ODR/MODE/LP_MODE; LP mode 1 is 12-bit; WAKE_UP_THS LSB = FS/64;
LIR latched interrupts; CTRL7 INTERRUPTS_ENABLE.
* WS2812B-2020-V6: T0H 220 to 380 ns, T1H/T0L/T1L 580 ns to 1 us, bit at least 1.25 us, reset 280 us,
12 mA per channel.