← Commit history
README.md+156−1
@@ -1,3 +1,158 @@+<!-- studio:begin --> # Ghost Hand -A pocket IR transponder that keeps an automatic sink faucet running while you use it: learns the faucet's timeout, re-arms with the shortest blip, wakes when set down.+Ghost Hand is a small battery gadget you set on the counter beside a sink with an automatic faucet. It+aims infrared light at the faucet's sensor so the water keeps running while you wash your hair, do the+dishes or fill a jug, then hands the faucet back when you are done. It learns each faucet's safety+timeout by itself and re-arms with the shortest possible blip, so the water does not stop. There are no+settings and no power button: it wakes when it is set down and sleeps when it is picked up.++> **Safety.** Ghost Hand is for someone standing at the sink. It never runs unattended: a hard cap of+> 10 minutes per session, it stops the moment it is picked up or tipped, and it stops when its battery is+> low. It will not run while it is charging. Nothing beyond what an ordinary manual tap already allows.++| | |+|---|---|+| Board | Adom molecule, 44 x 28 mm, 2 layers on a 3rd party fab's rules, 4 corner machine pins |+| Brain | ATtiny1616, asleep between faucet pulses |+| IR | one 850 nm and two 940 nm emitters, two VEMD2023 photodiodes (750 to 1050 nm) |+| Battery | 500 mAh 1S LiPo, USB-C charging at 196 mA in 3.2 h |+| Battery life | about 897 ten-minute sessions per charge, 391 days at one a day |+| Lights | WS2812B-2020 under a clear-resin Adom logo light pipe and a 4-segment charge gauge |+| License | MIT |++## How it works++An automatic faucet is an active reflective sensor. Behind its dark window an IR LED flashes for 10 to+20 us every 0.3 to 2 s, and a receiver listens only during that flash. When enough light comes back (a+hand), the water turns on. Every faucet also has a safety timeout: after 10 to 60 s it shuts off even+with a hand there, and ignores that still hand until it goes away.++Ghost Hand hears the faucet's own flash on two photodiodes. A TLV9062 turns the photocurrent into volts+(a 150 k transimpedance stage, then gain 11), the ATtiny1616's analog comparator trips, and the event+system fires two single-shot timers that light the emitters, all in hardware, under 1 us after the+faucet's pulse begins. The echo therefore lands inside the faucet's own listening window and reads as a+strong reflection. The CPU only decides *when* to echo.++![Architecture flowchart in Ghost Hand Studio](docs/studio/architecture.png)++### Wavelengths and faucets covered++Faucet sensors work at 850 to 950 nm. One 850 nm emitter (VSMY2853SL) and two 940 nm emitters+(VSMB2948SL) cover that band, and the VEMD2023 photodiodes hear 750 to 1050 nm, so one Ghost Hand works on+the common brands:++| Faucet | Safety timeout | Notes |+|---|---|---|+| Moen M-Power 8551/8553 | 30 s | range 3 to 12 in, optional metering 10 to 180 s |+| Sloan EBF-615 | 30 s | range 2 to 7 in; EAF-700 sets its range at power-up |+| American Standard Selectronic | 55 s default (5 to 120 s) | 1.5 s run-on after hands leave |+| Zurn AquaSense Z6950 | 30 s default (5, 10, 15, 60 s) | self-calibrates to the basin |+| Kohler K-13460 | 30 s maximum run | |+| TOTO EcoPower | 10 or 60 s by model | |++![Faucet and IR beam explainer](docs/studio/ir.png)++### Learning the faucet++Just before the faucet's timeout, Ghost Hand goes quiet for a moment (a *blip*) so the faucet sees the+hand leave and come back, and starts a fresh run. The faucet's pulse rate is the window into its state:+most pulse faster while they see a user. Ghost Hand listens for 2 s, measures the faucet's idle period+and pulse width, echoes until the rate drops (that is the timeout), then re-arms at the timeout minus a+1.5 s margin. Each good blip is 15 % shorter than the last, never below a floor it learns+from its first miss. Faucets whose rate never changes get a blip every 8 s. What it learns is kept in+EEPROM, keyed to the faucet's idle period.++![Firmware state machine and the re-arm blip timeline](docs/studio/learning.png)++## The lights++| State | Logo | Charge gauge |+|---|---|---|+| Session start | wakes dim | fills to the battery level over 1.5 s (amber at 25 % or less) |+| Armed | soft cyan-white breathing, 4 s | off |+| Echoing | the breath 50 % brighter | off |+| Re-arm blip | quick flicker | off |+| Done (10 min) | slow warm fade | 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 |++![LED state guide with the engraved case top](docs/studio/lights.png)++## Charging and battery life++USB-C 5 V feeds an MCP73831 linear charger at 196 mA (about 0.39 C, gentle for a+cell in a sealed case): constant current, then constant voltage at 4.2 V, full in about+3.2 h. A TPS7A02 makes the 3.3 V rail on 25 nA of quiescent current.++![USB-C charging sequence](docs/studio/charging.png)++The budget is built from the typical current of every part (Adafruit 1578, 500 mAh 1S LiPo, 425 mAh usable,+stop at 3.45 V):++| | |+|---|---|+| Asleep on the counter | 4.7 uA (the pack's protection IC is most of it) |+| During a session | 2.84 mA average |+| One 10-minute session | 0.474 mAh |+| Sessions per charge | about 897 |+| Days per charge | 391 at one session a day, 209 at three, 123 at six |+| Running time per charge | about 150 h |++Self-discharge of the cell (3.0 %/month assumed (0.5 mAh/day), larger than the sleep current) matters more than the sleep current.+The faucet's pulse rate and the light pattern are the two big unknowns.++![Battery-life numbers](docs/studio/battery.png)++## Board++The board is an Adom molecule designed with Adom AI Flow in KiCad: schematic, placement, routing, copper+pours, current and heat analysis, silkscreen and 3D, on a 3rd party fab's 2-layer rules. Its four corner+machine pins are the same pins that seat it in an Adom workcell, and in the case they press into+printed sockets that hold the board.++The AI Flow run, step by step: [board-claude-opus-5-5-20261106-1434](docs/runs/board-claude-opus-5-5-20261106-1434), [ghost-hand-claude-opus-5-5-20261106-1434](docs/runs/ghost-hand-claude-opus-5-5-20261106-1434). Build clips: [docs/clips.md](docs/clips.md).++## Case++The 3D-printed case is being designed now: a sealed seam with a TPU gasket, a sealed USB-C opening, a non-slip foot, an IR window aimed at the faucet, clear-resin light pipes (the main one the Adom logo) and UV fiber laser engraving that names each light. Files appear here as they are made.++## Firmware++Plain C for the ATtiny1616 (avr-gcc, about 13.1 KB of the 16 KB flash). The echo path is pure hardware:+AC0, the event system and TCB0/TCB1 single-shots. The CPU runs the state machine, the learner, the+lights and the safety checks, and a 1 s watchdog guards the main loop. Full source is in+[firmware/](firmware/README.md), with a host test that runs the learner against simulated faucets+(`make test`, 36 checks). Flash over UPDI on machine contacts MC4 (UPDI), MC3 (3V3) and MC1 (GND).++| File | What |+|---|---|+| [firmware/src/main.c](firmware/src/main.c) | state machine and safety checks |+| [firmware/src/echo.c](firmware/src/echo.c) | AC0 + event system + TCB transponder |+| [firmware/src/learn.c](firmware/src/learn.c) | faucet timeout and blip learner |+| [firmware/src/accel.c](firmware/src/accel.c) | LIS2DW12 set-down, pick-up and tilt |+| [firmware/src/leds.c](firmware/src/leds.c) | WS2812B-2020 driver and light patterns |+| [firmware/src/power.c](firmware/src/power.c) | clocks, sleep, battery, ADC, watchdog, EEPROM |+| [firmware/build/ghost-hand.hex](firmware/build/ghost-hand.hex) | ready-to-flash image |++## Ghost Hand Studio++The build dashboard: an Adom app that shows the architecture, the faucet and its beam, the learning state+machine, the lights, charging, battery life and the 3D models, live as the board and case come together.++![Ghost Hand Studio 3D view](docs/studio/model3d.png)++## Downloads++| | |+|---|---|+| Firmware | [ghost-hand.hex](firmware/build/ghost-hand.hex), [source](firmware/README.md) |++Gerbers, BOM and CPL appear here when AI Flow exports the finished board.++## License++MIT. Build it, change it, share it.+<!-- studio:end -->
docs/studio/architecture.pngadded
⋯ 1 unchanged line ⋯
docs/studio/battery.pngadded
⋯ 1 unchanged line ⋯
docs/studio/charging.pngadded
⋯ 1 unchanged line ⋯
docs/studio/ir.pngadded
⋯ 1 unchanged line ⋯
docs/studio/learning.pngadded
⋯ 1 unchanged line ⋯
docs/studio/lights.pngadded
⋯ 1 unchanged line ⋯
docs/studio/model3d.pngadded
⋯ 1 unchanged line ⋯
firmware/.gitignoreadded+3
@@ -0,0 +1,3 @@+.toolchain/+build/+test/test_learn
firmware/Makefileadded+87
@@ -0,0 +1,87 @@+# Ghost Hand firmware : ATtiny1616 @ 5 MHz (OSC20M / 4), avr-gcc + avr-libc+#+#   make              build build/ghost-hand.elf/.hex and print the size+#   make test         host-side learner tests (plain gcc)+#   make flash        program over UPDI with pymcuprog (serial UPDI adapter)+#   make flash-avrdude  same with avrdude -c serialupdi+#   make fuses        write the fuses listed below+#+# Toolchain: uses avr-gcc on PATH if it knows the attiny1616, otherwise the+# user-space Microchip toolchain + ATtiny DFP unpacked in .toolchain/ (see README).++PORT     ?= /dev/ttyUSB0+MCU      := attiny1616+TARGET   := ghost-hand+BUILD    := build+SRC      := $(wildcard src/*.c)+OBJ      := $(patsubst src/%.c,$(BUILD)/%.o,$(SRC))++TC       := .toolchain/avr8-gnu-toolchain-linux_x86_64/bin+DFP      := .toolchain/dfp+ifneq ($(wildcard $(TC)/avr-gcc),)+  CC      := $(TC)/avr-gcc+  OBJCOPY := $(TC)/avr-objcopy+  SIZE    := $(TC)/avr-size+  DEVFLAGS := -B $(DFP)/gcc/dev/$(MCU) -isystem $(DFP)/include+else+  CC      := avr-gcc+  OBJCOPY := avr-objcopy+  SIZE    := avr-size+  DEVFLAGS :=+endif++CFLAGS   := -mmcu=$(MCU) $(DEVFLAGS) -DF_CPU=5000000UL -Os -std=gnu11 \+            -Wall -Wextra -Wno-unused-parameter -ffunction-sections -fdata-sections \+            -fshort-enums -flto+LDFLAGS  := -mmcu=$(MCU) $(DEVFLAGS) -Wl,--gc-sections -flto -Os++# Fuses (ATtiny1614/16/17 datasheet 6.10.3 Fuse Summary)+#   WDTCFG  (0) 0x00  watchdog off at reset, firmware enables it (1 s) while awake+#   BODCFG  (1) 0x44  BOD 2.6 V (LVL=2), enabled in active, disabled in sleep (saves ~1 uA)+#   OSCCFG  (2) 0x02  FREQSEL 20 MHz (the firmware divides by 4 -> 5 MHz, by 2 -> 10 MHz)+#   TCD0CFG (4) 0x00+#   SYSCFG0 (5) 0xC5  CRCSRC none, RSTPINCFG UPDI, EESAVE=1 (keep the learnt faucet across reflash)+#   SYSCFG1 (6) 0x07  64 ms start-up+#   APPEND  (7) 0x00, BOOTEND (8) 0x00  no boot section+FUSES    := 0:0x00 1:0x44 2:0x02 4:0x00 5:0xC5 6:0x07 7:0x00 8:0x00++.PHONY: all size test flash flash-avrdude fuses fuses-avrdude clean++all: $(BUILD)/$(TARGET).hex size++$(BUILD)/%.o: src/%.c src/*.h | $(BUILD)+	$(CC) $(CFLAGS) -c $< -o $@++$(BUILD)/$(TARGET).elf: $(OBJ)+	$(CC) $(LDFLAGS) $^ -o $@ -Wl,-Map,$(BUILD)/$(TARGET).map++$(BUILD)/$(TARGET).hex: $(BUILD)/$(TARGET).elf+	$(OBJCOPY) -O ihex -R .eeprom -R .fuse -R .lock -R .signature $< $@++$(BUILD):+	mkdir -p $@++size: $(BUILD)/$(TARGET).elf+	$(SIZE) $<+	@echo "ATtiny1616: 16384 B flash, 2048 B SRAM, 256 B EEPROM"++test:+	$(MAKE) -C test++flash: $(BUILD)/$(TARGET).hex+	pymcuprog write -t uart -u $(PORT) -d $(MCU) -f $< --erase --verify++flash-avrdude: $(BUILD)/$(TARGET).hex+	avrdude -c serialupdi -P $(PORT) -p t1616 -U flash:w:$<:i++fuses:+	pymcuprog write -t uart -u $(PORT) -d $(MCU) -m fuses -o 0 -l 0x00 0x44 0x02+	pymcuprog write -t uart -u $(PORT) -d $(MCU) -m fuses -o 4 -l 0x00 0xC5 0x07 0x00 0x00++fuses-avrdude:+	avrdude -c serialupdi -P $(PORT) -p t1616 -U wdtcfg:w:0x00:m -U bodcfg:w:0x44:m \+	  -U osccfg:w:0x02:m -U syscfg0:w:0xC5:m -U syscfg1:w:0x07:m -U append:w:0x00:m -U bootend:w:0x00:m++clean:+	rm -rf $(BUILD)+	$(MAKE) -C test clean
firmware/README.mdadded+295
@@ -0,0 +1,295 @@+# Ghost Hand firmware++Firmware for Ghost Hand rev A: 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 logo 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` |++## 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 (25 C, 500 mAh pack, 425 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.84 mA average (MCU 0.8, receiver 1.08, bias 0.03, echo pulses 0.33, light 0.6, accel 0.004) |++That gives about 0.47 mAh per 10-minute session, roughly 900 sessions per charge, and 10.3 years of+pure sleep (self-discharge of the cell dominates long before that). Charging takes about 3.2 h at 196 mA.++## LED behaviour++| State | Logo (LED 0) | Gauge (LEDs 1 to 4) |+|---|---|---|+| 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 |++Total brightness is capped (sum of all 15 channel values at most 950, about 45 mA at 12 mA per channel)+so the 3.3 V LDO stays 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+  13130       2     366   13498    34ba 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.
firmware/build/ghost-hand.hexadded+823
@@ -0,0 +1,823 @@+:100000000C943E000C945B000C945B000C94430831
+:100010000C942E080C945B000C9470080C945808F7
+:100020000C948A080C945B000C945B000C945B00AD
+:100030000C945B000C94B7080C945B000C945B0070
+:100040000C945B000C945B000C945B000C945B00C4
+:100050000C945B000C945B000C945B000C945B00B4
+:100060000C945B000C945B000C945B000C945B00A4
+:100070000C945B000C945B000C945B0011241FBE7D
+:10008000CFEFCDBFDFE3DEBF18E3A0E0B8E3EAE4E3
+:10009000F3E302C005900D92A230B107D9F729E32E
+:1000A000A2E0B8E301C01D92A037B207E1F70E94B9
+:1000B00050090C948E190C9400004F925F926F922D
+:1000C0007F928F929F92AF92BF92CF92DF92EF92E8
+:1000D000FF920F931F93FC01AAE6B9E3CD90DC9049
+:1000E00011970D2C000CEE08FF0840815181052E60
+:1000F000000C660B770B97018601041B150B260B72
+:10010000370BC901B80112968D909C901397092C5A
+:10011000000CAA08BB08C280D3800D2C000CEE088E
+:10012000FF088C189D08AE08BF081496CD90DC908F
+:100130000D2C000CEE08FF0844805580052C000CA7
+:1001400066087708C418D508E608F7089801AC01D6
+:100150000E94DA182B013C01A5019401C501B401EC
+:100160000E94DA18460E571E681E791EA7019601D6
+:10017000C701B6010E94DA18640D751D861D971D12
+:100180001F910F91FF90EF90DF90CF90BF90AF90B5
+:100190009F908F907F906F905F904F9008954F92B7
+:1001A0005F926F927F928F929F92AF92BF92CF9207
+:1001B000DF92EF92FF920F931F93CF93DF93DC01B7
+:1001C000C0906A39D0906B39D7FE03C08FE1C80E5A
+:1001D000D11C65E0D594C7946A95E1F7E0906C393D
+:1001E000F0906D39F7FE03C08FE1E80EF11C55E089
+:1001F000F594E7945A95E1F700916E3910916F39B3
+:1002000017FF02C0015E1F4F45E0159507954A95FF
+:10021000E1F7CD91DC911197D7FD6F9635E0D5953B
+:10022000C7953A95E1F71296ED91FC911397F7FD7A
+:100230007F9625E0F595E7952A95E1F714964D917F
+:100240005C9157FF02C0415E5F4F95E05595479521
+:100250009A95E1F79601DE010E9465194B015C0158
+:100260009701DF010E946519860E971EA81EB91E10
+:100270009801DA010E946519860E971EA81EB91E04
+:10028000181419041A041B040CF06DC09601D60151
+:100290000E9465192B013C019701D7010E94651945
+:1002A00073016201C60ED71EE81EF91E9801D8011F
+:1002B0000E946519C60ED71EE81EF91EC114D1048E
+:1002C000E104F10409F44FC09E01DE010E946519AA
+:1002D0002B013C019F01DF010E946519460E571E4C
+:1002E000681E791E9A01DA010E946519460E571E92
+:1002F000681E791E4114510461047104A1F1A50125
+:100300009401C501B4010E94DA184B015C0182E03E
+:10031000880C991CAA1CBB1C8A95D1F7A3E0B0E0FD
+:10032000A70196010E9483199B01AC01C301B20190
+:100330000E94DA1821E086169706A806B90608F08A
+:1003400020E0822FDF91CF911F910F91FF90EF90CE
+:10035000DF90CF90BF90AF909F908F907F906F90E5
+:100360005F904F90089521E0ECCFFC0123E18FEFE7
+:1003700041E3215009F408959191982738E097FFBF
+:1003800006C0990F94273150892FC9F7F2CF990FE2
+:10039000FACF8F929F92AF92BF92CF92DF92EF925D
+:1003A000FF9221E02093233920ED37E0662311F0FE
+:1003B00020E43FE16C01F12CE12C48E7C416D104A4
+:1003C000E104F104D0F14901B12CA12C8C149D045D
+:1003D000AE04BF0408F09C0120935D3930935E3970
+:1003E0008091293990912A39A0912B39B0912C39DB
+:1003F000820F931FA11DB11D80934D3990934E39EB
+:10040000A0934F39B093503960934C39809164393F
+:100410009091653901968093643990936539809104
+:100420002439816080932439FF90EF90DF90CF9042
+:10043000BF90AF909F908F90089528E730E0CCCF89
+:10044000CF92DF92EF92FF920F931F93CF93DF93A0
+:1004500000915D3910915E398091683990916939C8
+:10046000019680936839909369399801A3E7B0E0C9
+:100470000E946D1924E630E040E050E00E941E1911
+:1004800069017A01C0911939D0911A392097D9F0B0
+:100490002091273930912839ABE0B0E00E946D19E6
+:1004A0002AE030E040E050E00E941E19DA01C90164
+:1004B0008C0F9D1FA11DB11DC816D906EA06FB06AB
+:1004C00010F46C017D0121EDC21627E0D206E10493
+:1004D000F10430F080EDC82E87E0D82EE12CF12C0D
+:1004E000C0911739D0911839CC15DD0508F4E60113
+:1004F000C0931739D09318399801ADE0B0E00E944D
+:100500006D192AE030E040E050E00E941E192837C3
+:1005100031054105510560F1213D87E0380741056E
+:10052000510548F5C217D30708F4E901C0931539FE
+:10053000D093163980911B3990911C398C509E4F65
+:10054000813A2FE0920710F080EA9FE080931B39F8
+:1005500090931C3980912439806680932439DF91EF
+:10056000CF911F910F91FF90EF90DF90CF90089562
+:1005700028E730E0D7CF20ED37E0D4CF0F931F939B
+:10058000409125395091263970E060E04E5C5F4F14
+:100590006F4F7F4F440F551F661F771F80915F3944
+:1005A00090916039009115391091163930E020E0B2
+:1005B000B0E0A0E080179107A207B30710F4D901BB
+:1005C000C80184179507A607B70710F4DB01CA0115
+:1005D00088379105A105B10558F081342FE19207C4
+:1005E000A105B10540F461E01F910F910C94C90180
+:1005F00088E790E0F8CF80E49FE1F5CF683B2BE0FF
+:1006000072078105910538F1693E2DEF720781056A
+:10061000910520F068EE7DEF80E090E08091133945
+:1006200090911439009759F06817790740F0681BCA
+:10063000790B7695679576956795680F791F609326
+:1006400013397093143981E080934B3980912439A8
+:100650008864809324390895CF92DF92EF92FF92BD
+:10066000CF93DF9380912239813009F07FC0809150
+:100670002339811158C0C0903539D0903639E09077
+:100680003739F09038398091423990914339A091AF
+:100690004439B0914539C81AD90AEA0AFB0AC0910F
+:1006A0001339D0911439C701B6010E94FE02209778
+:1006B00021F128EBC2162BE0D206E104F104E8F0A8
+:1006C000CE01B0E0A0E0C816D906EA06FB06A8F401
+:1006D00080911B3990911C398C509E4F813A2FE0AC
+:1006E000920710F080EA9FE080931B3990931C39A9
+:1006F000809124398064809324398091623990916B
+:1007000063390196809362399093633980912439DB
+:10071000846080932439DF91CF91FF90EF90DF9038
+:10072000CF900C94BE02823009F580916139882304
+:10073000E9F080914C398111E0CF609135397091A9
+:1007400036398091373990913839C0904239D090FC
+:100750004339E0904439F09045396C197D098E0990
+:100760009F090E94FE020E942002C7CFDF91CF9115
+:10077000FF90EF90DF90CF900895CF938091FC3859
+:100780009091FD388834974489F48091FE38813007
+:1007900069F4C0910E398CEF98E30E94B50191E0A5
+:1007A000C81390E0892F8170CF91089580E0FBCF2E
+:1007B0000F931F93CF93A6E3B3EB41E050E0ED918D
+:1007C000FD91E817F90778F1BA0161507109DB0171
+:1007D000AA0FBB1FAC5CBC440D911C91E01BF10B3C
+:1007E000675D7C44DB01CC91475D5C44DA012C9170
+:1007F000422F4C1B550BDA019C01201B310B0E9430
+:100800007C199F0136952795620F731F811D911DDD
+:100810009F0150E040E00E941E198C2F820FCF9163
+:100820001F910F9108954F5F5F4F4B30510539F67F
+:1008300084E6F5CF8091010180FDFCCF88ED98E042
+:1008400084BF9093000108959091A0009F7830E1BB
+:10085000611130E2932B9093A00010920006409318
+:10086000010692E49093020690E49093030620938D
+:1008700005068093060681E080930B0680930006B0
+:100880008093080680910B0680FFFCCF2091100614
+:10089000309111061092000602C0369527954A95B0
+:1008A000E2F750E040E0611110C06CE474E080E0D9
+:1008B00090E00E94DA18DC01CB012AE0B695A795FA
+:1008C000979587952A95D1F7089564EC79E080E0B3
+:1008D00090E0EFCFCF92DF92EF92FF92CF93C4E000
+:1008E000C12CD12C76012FE144E061E086E00E942A
+:1008F0002404C80ED91EE11CF11CC150A1F7D70178
+:10090000C6010196A11DB11D880F991FAA1FBB1F0B
+:1009100032E0B695A795979587953A95D1F7CF91FF
+:10092000FF90EF90DF90CF9008958091280482FB94
+:10093000882780F908959FB7F8948091FA3810922B
+:10094000FA389FBF08950F931F938FB7F894609163
+:10095000F7387091F8380091480110914901909151
+:10096000430190FF04C017FD02C06F5F7F4F8FBF30
+:1009700090E080E0CB017727662730E020E0602B15
+:10098000712B822B932B1F910F9108950E94A3042A
+:100990009B01AC01ADE7B0E00E94831927E096957A
+:1009A0008795779567952A95D1F70895CF92DF922D
+:1009B000EF92FF926B017C0180912539909126394D
+:1009C00080931D3990931E39809127399091283951
+:1009D00080931F39909320398FE099E30E94B501ED
+:1009E0008093213983E1EFE0F9E3ACEFB8E30190C4
+:1009F0000D928A95E1F78091021081FDFCCF8DE97F
+:100A000094E084BF90930010ACEFB8E3E0E0F4E131
+:100A10008D9180833196E33184E1F807C9F78DE940
+:100A200093E084BF909300108091021081FDFCCF71
+:100A30001092F638C092F238D092F338E092F4383F
+:100A4000F092F538FF90EF90DF90CF900895CF931C
+:100A5000C82F0E946A04CC2329F08536910548F0FE
+:100A6000845691098B372DE0920718F0CF910C94A2
+:100A7000D80380E0CF910895DF92EF92FF920F9319
+:100A80001F93CF93DF93482FF62E0091C438EBEDE0
+:100A9000F8E330E088E2E82ECFEFD0E096E0D92E00
+:100AA0002FEF341718F02F2D431320E02E9DC00197
+:100AB0001124BE010E94F618162F3F5F0A31A8F4D8
+:100AC00050E1259FC0011124BE010E94F6186383E6
+:100AD000148315823396343019F7DF91CF911F912B
+:100AE0000F91FF90EF90DF900895638314822D9D06
+:100AF000C0011124BE010E94F6186583EBCF80E28D
+:100B00008093260480E1809325041092CB381092C4
+:100B1000C538ECECF8E3119288E3EB3DF807D9F720
+:100B20000895F89480938F38882331F010929201C1
+:100B300010929D01789408958091EA388111FACF3E
+:100B40008091400A80FFF6CF83E0809392018093EA
+:100B50009D01F0CF8091EA3881110AC08091470A47
+:100B600080FD06C08091570A80958170817008953C
+:100B700080E0FCCFCF93DF93F894E0E8F1E0128AB5
+:100B8000158EA0E4BAE01C9215961C92159711964A
+:100B90001C92A0E5BAE01C9211961C92A0E0BAE06B
+:100BA0001C921A961C921092EA38109298387894F7
+:100BB000A0E0B4E088E216968C93169712968C9378
+:100BC0001297C0E0D2E088818B7F8883148E1282D6
+:100BD00084E080934604109280061092A006E0EA1A
+:100BE000F0E081818E7F818380E116968C93109254
+:100BF000973810929638DF91CF9108950E94BA05E8
+:100C00000E94C6042091F63821110C94D604089550
+:100C10002FB7F8944091973830919638431741F1A7
+:100C2000A0919738E6E0EA9FD0011124AE5FB74C5F
+:100C30004D915D916D917C91DC014D935D936D9330
+:100C40007C93139730919738E39FF0011124EE5F66
+:100C5000F74C4481558114964D935C931597809180
+:100C600097388F5F8F708093973881E02FBF0895FA
+:100C700080E0FCCF209196382F5F322F3F7020917B
+:100C800097382317C1F0A0919638E6E0EA9FD0018B
+:100C90001124AE5FB74C6D937D938D939C93139706
+:100CA00090919638E99FF0011124EE5FF74C448350
+:100CB000558330939638089520EA3FE090911508C7
+:100CC000982311F081E00895909115089C7019F413
+:100CD0002150310999F780E008958093170880EC3E
+:100CE0000E945C06811107C093E09093140891E084
+:100CF0009093150808959091150894FF04C083E01F
+:100D00008093140880E00895CF92DF92EF92FF92D3
+:100D10000F931F93CF93DF93C82F8B01D42F82E3C0
+:100D20000E946D06811102C0C0E00BC0C09318087C
+:100D300080E40E945C06C82F81110DC083E080937F
+:100D400014088C2FDF91CF911F910F91FF90EF909E
+:100D5000DF90CF90089583E30E946D06C82F88230B
+:100D600019F3780101E010E0CD2ED12C8091180804
+:100D7000F70181937F0182E0C016D10609F487E074
+:100D8000809314080D17E8F680E80E945C06811134
+:100D900004C083E080931408C7CF0F5F1F4FE6CFD6
+:100DA000DF92EF92FF920F931F93CF93DF93CDB714
+:100DB000DEB72C97CDBFDEBF8C0146E0BE016F5F72
+:100DC0007F4F88E20E948406D82E882371F1FE01AD
+:100DD0003196AE01495F5F4F7A01A1818081BA2FC0
+:100DE000A82F84E0B595A7958A95E1F72AEF30E022
+:100DF0000E947C19672F782F892F992787FD9A9554
+:100E0000DA016D937D93AD013296EE16FF0629F758
+:100E10008F819885F8018083918389859A85828363
+:100E200093838B859C85848395838D2D2C96CDBF54
+:100E3000DEBFDF91CF911F910F91FF90EF90DF9078
+:100E40000895CF93DF931F92CDB7DEB741E0BE0187
+:100E50006F5F7F4F88E30E94840641E0BE016F5FB1
+:100E60007F4F8BE30E9484060F90DF91CF9108950E
+:100E7000CF93DF93D82FC62F82E30E946D0688237D
+:100E800051F0D093180880E40E945C06811106C0DE
+:100E900093E090931408DF91CF910895C0931808C0
+:100EA00080E40E945C06F4CFE0E1F8E084E1868310
+:100EB00081E08383858360E480E20E94380768E0F4
+:100EC00084E30E94380760E283E20E9438070C94B2
+:100ED0002107CF92DF92EF92FF92CF93DF930E9490
+:100EE000BA050E947F058091F1388823B1F084E132
+:100EF0008093160881E0809313088093150860E2C0
+:100F000080E20E94380762E084E30E94380760E2D2
+:100F100083E20E9438070E942107109213088091F3
+:100F200051018111FCCF1092500180910101982F45
+:100F3000917080FDFACF88ED84BF909300010E94EC
+:100F40009B048FB7F8949091FB381092FB388FBFB9
+:100F5000C5E0F8948091080482FD43C00E94950486
+:100F600080FF42C07894809151018111FCCFD1E083
+:100F7000D093520181E4809350010E941A040E9490
+:100F8000C6046B017C010E949504C091F13888234E
+:100F900009F443C0D5E0D093F038C092EC38D09239
+:100FA000ED38E092EE38F092EF381092C13881E0DF
+:100FB0000E942705D093C5388093C438C092C638A4
+:100FC000D092C738E092C838F092C938CC2309F4DF
+:100FD0003EC0DF91CF91FF90EF90DF90CF900C94C7
+:100FE000540781E090E0BCCFC0935000789488957E
+:100FF000109250000E949B048111B5CF9FB7F894C6
+:101000008091FB381092FB389FBF8111ACCF0E94BA
+:101010009504882309F49DCFA6CFD093F038C092D1
+:10102000EC38D092ED38E092EE38F092EF38CC23E5
+:1010300031F00E94540786E698E30E94D006C092E1
+:101040006238D0926338E0926438F0926538DF916C
+:10105000CF91FF90EF90DF90CF9008951F920F9265
+:101060000FB60F9211248F93809129048093290445
+:1010700082FF03C081E08093FB388F910F900FBEF9
+:101080000F901F9018951F920F920FB60F92112478
+:101090008F93809109048093090482FF03C081E04B
+:1010A0008093FA388F910F900FBE0F901F90189574
+:1010B0001F920F920FB60F9211248F9381E08093AD
+:1010C00053018091F938883028F48091F9388F5F86
+:1010D0008093F9388F910F900FBE0F901F90189545
+:1010E0001F920F920FB60F9211248F939F9381E05E
+:1010F000809343018091F7389091F838019680935E
+:10110000F7389093F8389F918F910F900FBE0F9002
+:101110001F9018951F920F920FB60F9211248F9364
+:101120009F9381E080930B0A8091870684FF11C012
+:101130001092200A1092210A80E190E08093260A02
+:101140009093270A9F918F910F900FBE0F901F9041
+:1011500018951092000A1092EA3880918F38811108
+:10116000F1CF83E08093920180939D01EBCF1F929A
+:101170000F920FB60F921124CF92DF92EF92FF924F
+:101180000F931F932F933F934F935F936F937F938F
+:101190008F939F93AF93BF93EF93FF930E94A3040A
+:1011A0006B017C0180919838882311F140914C0AA1
+:1011B00050914D0AC701B6010E943A0681E0809322
+:1011C000460AFF91EF91BF91AF919F918F917F91CF
+:1011D0006F915F914F913F912F911F910F91FF90D0
+:1011E000EF90DF90CF900F900FBE0F901F9018954B
+:1011F00081E08093460A1092920110929D0150E086
+:1012000040E0C701B6010E943A06409199385091DA
+:101210009A388091923890919338A0919438B091F7
+:10122000953897018601081B190B2A0B3B0BD90136
+:10123000C8010A97A105B10560F0C0929238D0921A
+:101240009338E0929438F09295381092903810923A
+:1012500091388091003890E0209190383091913809
+:10126000280F391F2093903830939138209190386F
+:1012700030919138820F931F893C910510F045E31E
+:101280005CE01092200A1092210A4093260A5093A3
+:10129000270A89E08093000A81E08093EA3891CFA1
+:1012A000CF93DF93CDB7DEB7E397CDBFDEBF88ED39
+:1012B00090E084BF9093600023E030E084BF2093EF
+:1012C000610088E38093060480E18093000410921B
+:1012D000110494E0909316049093170480932404CF
+:1012E00080E38093200488E08093330410924404C8
+:1012F00090934004809351048093530410925004BF
+:10130000809141018111FCCF109247018FEF9FEF37
+:1013100080934A0190934B0181E08093420189EAD6
+:1013200080934001809151018111FCCF81E0809335
+:10133000520181E48093500180E18093250480E292
+:101340008093260480E3809321041092CB380CEC28
+:1013500018E30DA31EA3ADA1BEA11D92ADA3BEA314
+:10136000AB5DB843C1F788E38093060488E28093BD
+:10137000020480E18093010484E0809317041092BA
+:10138000400A1092500A109280061092A006109205
+:10139000920110929D0182E08093120481E080937B
+:1013A000320478940E941A048091021081FDFCCFCF
+:1013B000ACEFB8E3E0E0F4E180818D933196E33166
+:1013C00004E1F007C9F70E94BD03811131C088E430
+:1013D00097E48093FC389093FD3881E08093FE3849
+:1013E0001092FF38109200391092013988E592E08E
+:1013F0008093023990930339109204391092053981
+:1014000010920639109207398CED95E080930839D7
+:101410009093093910920A3910920B3910920C39B5
+:1014200010920D398CEF98E30E94B50180930E392C
+:10143000198284E18093160881E0809313088093D9
+:10144000150841E0BE016F5F7F4F8FE00E94840668
+:10145000182F8823F1F18981843409F07AC060E47F
+:1014600081E20E94380781EE94E00197F1F700C015
+:101470000000A1EEB4E01197F1F700C00000E1EE2A
+:10148000F4E03197F1F700C0000081EE94E001979D
+:10149000F1F700C00000A1EEB4E01197F1F700C031
+:1014A00000006CE081E20E94380760E085E20E9463
+:1014B000380760E182E20E94380760E085E30E941D
+:1014C000380760E28FE30E94380760E283E20E94FF
+:1014D00038071093F1380E94C6046B017C0111118A
+:1014E0000E9454070E9495048823A9F115E01093E7
+:1014F000F038C092EC38D092ED38E092EE38F092AD
+:10150000EF3881E00E9427051093C5388093C438D6
+:10151000C092C638D092C738E092C838F092C93825
+:101520009FB7F8948091F93881112AC09FBF8091AC
+:10153000F9388111F5CF8091EA3881110C94D718D0
+:1015400082E08160809350007894889510925000DA
+:10155000E7CF10E0BECF86E08093F038C092EC3841
+:10156000D092ED38E092EE38F092EF3880E00E94B1
+:10157000270597E0111101C094E09093C538C6CFBC
+:101580008091F93881508093F9389FBFA8950E94C7
+:10159000C6042B013C011091EB381F5F1093EB3810
+:1015A000E091F038E73008F02FC1F0E0E652F54F57
+:1015B0000C945F19FD0AE10A590C66157D15941506
+:1015C000EF150E9495048823A1F015E01093F038E0
+:1015D0004092EC385092ED386092EE387092EF38CD
+:1015E0001092C13881E00E9427051093C5380C94F1
+:1015F000C0158091F138811103C00E94690704C1B0
+:10160000137009F001C18AE699E30E94D00688238D
+:1016100009F4FAC086E698E30E945D0061317E40DD
+:101620008105910588F086E0EAE6F9E3A6E6B8E3ED
+:1016300001900D928A95E1F740926238509263389A
+:10164000609264387092653810917E381123B1F041
+:1016500080917D38811112C087E798E30E945D0078
+:101660006532744F8105910530F487E798E30E9455
+:10167000CF00882319F081E080937D3880E293EBDE
+:101680000E94CF0020917D38111102C021E030E08E
+:101690004091623850916338609164387091653838
+:1016A00073016201C41AD50AE60AF70AB701A60156
+:1016B0004C3D05E050076105710508F4E4C0811157
+:1016C000DCC020FFDAC010927E380E946A048C01D0
+:1016D0008A379D4060F584E08093F0384092EC3882
+:1016E0005092ED386092EE387092EF3891E090931E
+:1016F0007E3810927D3896E0EAE6F9E3A7E7B8E392
+:1017000001900D929A95E1F702301D4018F08093F8
+:10171000C53802C01092C5381092C4384092C638FD
+:101720005092C7386092C8387092C9386DC082E054
+:101730008093F0384092EC385092ED386092EE3859
+:101740007092EF384092BD385092BE386092BF38E8
+:101750007092C03840926D3850926E3860926F38F7
+:101760007092703810926C3886E0EAE6F9E3A1E7EF
+:10177000B8E301900D928A95E1F70E9454070E9408
+:101780009B0480E00A374DE0140719F0C8010E945D
+:10179000D80391E09093C5388093C4384092C638FE
+:1017A0005092C7386092C8387092C93880E18093EF
+:1017B00005048091A000887F81608093A0008091C3
+:1017C000A10081608093A10083E58093A10681E858
+:1017D0008093A00683E0809382061092860695E8A7
+:1017E000909380068093820180939C0184E0809393
+:1017F000460480934104809100028460809300023B
+:1018000010928F381092BC388091EB3880FD8FCECB
+:101810008091F038882309F48ACE0E94C604009192
+:10182000C6381091C7382091C8383091C9386B013B
+:101830007C01C01AD10AE20AF30A6BED862E68E336
+:10184000962E8AEE98E35401D5011D925D018A1708
+:101850009B07D1F7E091C538E150E73010F00C94C8
+:101860006516F0E0EA5CF34F0C945F191716D41676
+:10187000331772178B17EA17631848395A436105F3
+:10188000710508F0BACE8091EC389091ED38A091B6
+:10189000EE38B091EF38481A590A6A0A7B0A90EC80
+:1018A000491694ED590691E06906710408F0A5CE39
+:1018B000ABCFC090BD38D090BE38E090BF38F0902C
+:1018C000C038CFA2D8A6E9A6FAA65C014B018C18B5
+:1018D0009D08AE08BF0800EC801607E2900609E0FC
+:1018E000A006B104E0F00E94FE0583E08093F0388A
+:1018F0004092EC385092ED386092EE387092EF38AA
+:1019000091E090937E3810927D3896E0EAE6F9E314
+:10191000A7E7B8E301900D929A95E1F7F8CE0E94FF
+:101920009504882319F00E94FE054FCE0E949B0467
+:10193000882329F00E94FE050E947F055ECE137069
+:10194000A9F48AE699E30E94D006882399F381E7F7
+:1019500098E30E945D006532744F8105910550F750
+:1019600081E798E30E94CF008111E4CF80916D3828
+:1019700090916E38A0916F38B09170387301620108
+:10198000C81AD90AEA0AFB0AD701C6018031974270
+:10199000A105B105A0F00E94AA05882381F040921C
+:1019A0006D3850926E3860926F38709270380E9425
+:1019B0006A048C018A379D40E8F010926C3880915F
+:1019C000BC3811E0813009F46AC1E8F12224239483
+:1019D000823009F425C518EB81161BE09106A1049D
+:1019E000B10408F411CF8091C538813009F00CCFD3
+:1019F00082E08DCE80916C388F5F80936C3882301E
+:101A0000F0F20E94FE0584E08093F0384092EC38BA
+:101A10005092ED386092EE387092EF3881E080930A
+:101A20007E3810927D3886E0EAE6F9E3A7E7B8E36E
+:101A300001900D928A95E1F784E002301D4008F094
+:101A400066CE80E064CE4CE384169104A104B10418
+:101A500008F4DACEBE016F5F7F4F7B016E0171E249
+:101A6000C70ED11C870122E040E060E087E00E94C1
+:101A70002404D7018D939D937D01E1EEF4E031972D
+:101A8000F1F700C00000CA16DB0669F7FE013396C5
+:101A900090E041915191892F282F082E000C330B93
+:101AA000D901AA0FBB1FA00FB11F6D917C912F5FB1
+:101AB0003F4F4617570748F4220F331FD801A20F94
+:101AC000B31F6D937C93815038F7A82F880FBB0B01
+:101AD0001196AA0FBB1FA00FB11F4D935C939F5F80
+:101AE0009F30B9F6698D7A8D645C7F4F693EF3E073
+:101AF0007F0710F068EE73E06A3401E0700710F4BD
+:101B00006AE471E090E080E0982F872F762F6627B7
+:101B10002CE434E040E050E00E941E192093A1061E
+:101B20001092A6381092A7381092A8381092A938AF
+:101B30001092BA381092BB381092A5381092A4387F
+:101B4000F8941092920110929D011092500A1092F6
+:101B5000400A88E28093020484E08093410A81E095
+:101B60008093440A8093460A8093450A91E49093B7
+:101B7000400A80939838109297381092963893E0E4
+:101B80009093920190939D0178948093BC3823CFD9
+:101B900029813A814B815C818091A4388823A9F006
+:101BA00080919B3890919C38A0919D38B0919E383F
+:101BB00069017A01C81AD90AEA0AFB0AD701C601E3
+:101BC0000397A105B10508F46AC01093A4382093C7
+:101BD0009B3830939C3840939D3850939E38ADE7A6
+:101BE000B0E00E948319DC01CB0147E0B695A795D0
+:101BF000979587954A95D1F7CFA0D8A4E9A4FAA4E0
+:101C00008C199D09AE09BF092091BB38222339F1F7
+:101C1000E091BA38E83018F5C090A638D090A738CF
+:101C2000E090A838F090A938AC01BD014C195D09CD
+:101C30006E097F096A0141505109610971094F35E7
+:101C40005A4E6105710558F431E03E0F3093BA38B1
+:101C5000F0E0EE0FFF1FE655F74CC082D182809373
+:101C6000A6389093A738A093A838B093A9382F3FEF
+:101C700019F02F5F2093BB38E091A538E83078F455
+:101C80008D819E81009759F021E02E0F2093A53879
+:101C9000F0E0EE0FFF1FE158F74C80839183CE01F7
+:101CA00001960E940806811173CF0CE0801608E0AF
+:101CB0009006A104B10408F4A7CD8091BB3883300D
+:101CC00040F414EC811613E19106A104B10408F468
+:101CD00082CEF090A538EFE7F8E321E030E0F11094
+:101CE00020C069E139C062817381A9014150510965
+:101CF000DF01CA01019647FD0AC00D911C911197A1
+:101D00004150510912976017710708F489C0880F74
+:101D1000991FDC01A158B74C6D937C932F5F3F4F07
+:101D200032962F1500F3F694EF2DF0E0EE0FFF1F23
+:101D3000E158F74C8081918165E070E00E94F618CF
+:101D40006B5F7F4F6932710510F068E270E06830B8
+:101D5000710510F468E070E06093003815E0619F51
+:101D6000C001112402972FE230E02093993830937C
+:101D70009A38F8941092920110929D01109298381E
+:101D800028E2209306041092400A46E54093410A57
+:101D900080934C0A90934D0A80934A0A90934B0A81
+:101DA00021E02093440A2093460A2093450A31E417
+:101DB0003093400A1092500A4093510A80935C0A73
+:101DC00090935D0A80935A0A90935B0A2093540A79
+:101DD0002093560A1092550A3093500A1092000A26
+:101DE0001092010A20930B0A20930A0A1092EA38F3
+:101DF00010929738109296380E94A30460939238FC
+:101E000070939338809394389093953810929038CB
+:101E10001092913880918F38811107C083E006C0FD
+:101E200014960D931C93159764CF80E080939201D4
+:101E300080939D0178944091BA3810E000E04423EB
+:101E400021F18AEA98E3FE013196BF01F12CDC0111
+:101E50002D913D91CD01DB012D933D93BD01F39477
+:101E60004F11F5CF21E030E02F1508F423C1F6948F
+:101E7000EF2DF0E0EE0FFF1FCC24C394D12CCC0E3D
+:101E8000DD1EEC0DFD1D008111810E94BD03882324
+:101E900009F43DC10115110559F1E0910A39F0919C
+:101EA0000B39309729F1C801B0E0A0E0AF0170E034
+:101EB00060E0481759076A077B0708F021C16801ED
+:101EC0007D01C41AD50AE60AF70AA7019601AAE01D
+:101ED000B0E00E9483196B017C019F01A3E0B0E098
+:101EE0000E946D196C157D058E059F0508F40FC1C4
+:101EF00083E1ECEFF8E3AFE0B9E301900D928A954E
+:101F0000E1F70115110549F400911D3910911E39B1
+:101F10000115110511F408EE13E000932539109313
+:101F2000263960911F39709120396115710529F4A6
+:101F3000C80163E070E00E94F6186017710708F0AE
+:101F400018C1609327397093283910922239109262
+:101F50002339109224398092293990922A39A092FB
+:101F60002B39B0922C3980922D3990922E39A09233
+:101F70002F39B09230391092313910923239109293
+:101F80003339109234398092353990923639A09293
+:101F90003739B09238391092393910923A39109253
+:101FA0003B3910923C3910923D3980923E399092E3
+:101FB0003F39A0924039B092413980924239909293
+:101FC0004339A0924439B092453980924639909273
+:101FD0004739A0924839B092493910924A39109243
+:101FE0004B3910924C398092553990925639A092C3
+:101FF0005739B09258398092513990925239A09203
+:102000005339B092543980924D3990924E39A09202
+:102010004F39B09250390C501E4F000F111F013232
+:102020002EE4120710F000E21EE4083B3BE0130729
+:1020300010F408EB1BE0D501C401800F911FA11D16
+:10204000B11D8093593990935A39A0935B39B0935D
+:102050005C39809115399091163980935D39909350
+:102060005E3910925F391092603910926139109286
+:102070006839109269391092663910926739109256
+:102080006439109265391092623910926339109256
+:10209000A4381092A33840929F385092A038609292
+:1020A000A1387092A2381092020488E28093010451
+:1020B00082E06BCD62817381A90141505109DF013A
+:1020C000CA01019647FD09C00D911C91119741501D
+:1020D000510912976017710770F0880F991FA1E0DE
+:1020E000B0E0AC0FBD1FA80FB91F6D937C932F5F9D
+:1020F0003F4F3296B9CE14960D931C931597E0CFAF
+:102100009F01AB01281B390B4A0B5B0BE0CE88E427
+:1021100097E480930F399093103981E080931139BF
+:1021200010921239109213391092143988E592E006
+:1021300080931539909316391092173910921839E7
+:102140001092193910921A398CED95E080931B3951
+:1021500090931C3910921D3910921E3910921F391C
+:10216000109220398FE099E30E94B50180932139C4
+:10217000C8CEC80163E070E00E94F618E2CE298163
+:102180003A814B815C818091A4388823A9F08091A9
+:102190009B3890919C38A0919D38B0919E386901F0
+:1021A0007A01C81AD90AEA0AFB0AD701C6010397BD
+:1021B000A105B10508F434C12092A43820939B38BE
+:1021C00030939C3840939D3850939E38ADE7B0E0F3
+:1021D0000E948319DC01CB0147E0B695A79597953E
+:1021E00087954A95D1F77C018D012FA138A549A586
+:1021F0005AA5E21AF30A040B150BE9A2FAA20BA3E3
+:102200001CA38091393990913A39A0913B39B09112
+:102210003C390097A105B10509F47EC040912D39E4
+:1022200050912E3960912F397091303967017801C2
+:10223000C41AD50AE60AF70A09A11AA12BA13CA1E2
+:1022400000932D3910932E3920932F393093303944
+:102250000196A11DB11D8093393990933A39A0930D
+:102260003B39B0933C398091293990912A39A0911A
+:102270002B39B0912C3980179107A207B30740F48E
+:102280000093293910932A3920932B3930932C3914
+:10229000109123391BA7113091F080914A39811197
+:1022A0000EC009A11AA12BA13CA10093463910939D
+:1022B0004739209348393093493920924A39C1141B
+:1022C000D104E104F10409F4ABC000912539109167
+:1022D0002639980150E040E02CA73DA74EA75FA704
+:1022E000809122398130E1F4809127399091283909
+:1022F000280F391F411D511DDA01C901B695A79557
+:102300009795879533243394C816D906EA06FB06B9
+:1023100008F440C0312C3EC0C12CD12C76018CCFAA
+:10232000A5E0B0E0A70196010E94831968AB79ABE4
+:102330008AAB9BAB9801A3E0B0E00E946D1928A97D
+:1023400039A94AA95BA9261737074807590710F783
+:102350002092223920921239C0922739D0922839FE
+:1023600080914A39882309F444C18091463990917B
+:102370004739A0914839B0914939809342399093B7
+:102380004339A0934439B093453980912439806C06
+:102390008093243980912239813049F5332009F422
+:1023A00038C10091273910912839C701B601601B47
+:1023B000710B8109910928E030E040E050E00E9473
+:1023C0004019200F311F2530310508F41BC120931F
+:1023D00027393093283909A11AA12BA13CA10093D8
+:1023E0003539109336392093373930933839C092C4
+:1023F0003139D0923239E0923339F0923439332086
+:1024000029F00BA5023011F42092613980913D39F9
+:10241000831531F030923D39311031C10E942C03C7
+:10242000CE0101960E940806382E8111A8CE809216
+:10243000293990922A39A0922B39B0922C39809167
+:102440002239813009F053C080913D39882309F445
+:102450004EC080912D3990912E39A0912F39B09195
+:102460003039B501A401481B590B6A0B7B0B209135
+:102470002739309128398091253990912639690181
+:10248000F12CE12CC80ED91EE11CF11CF694E79446
+:10249000D794C794C416D506E606F70640F5DB01C7
+:1024A000CA01880F991FAA1FBB1F89A39AA3ABA3B8
+:1024B000BCA3A5E0B0E00E946D1909A11AA12BA14F
+:1024C0003CA1601771078207930768F0809131394A
+:1024D00090913239A0913339B0913439C816D90668
+:1024E000EA06FB0620F410923D390E942C039091DD
+:1024F0002239992309F4CAC080912339923009F412
+:10250000DEC0813009F494C108F42FC1823009F48F
+:102510002FC2809124391092243986FF03C081E0B4
+:102520008093F6388091F6388823C9F08091F2388C
+:102530009091F338A091F438B091F538B301A2012D
+:10254000481B590B6A0B7B0BDB01CA018033954793
+:10255000A105B10520F0C301B2010E94D60480910B
+:102560009F389091A038A091A138B091A238730102
+:102570006201C81AD90AEA0AFB0AD701C6018C32DD
+:102580009140A105B10590F08091A33840929F3809
+:102590005092A0386092A1387092A23890E00196D3
+:1025A00063E070E00E940A198093A33830912339C8
+:1025B000E091A338F0E0EA5DFC44908180E03130A6
+:1025C00029F080E090FD80E291FD886098E29827F4
+:1025D000909302048093010421E080E0313019F0EF
+:1025E00020E080913D3981708093C3382093C238B8
+:1025F000F2C989A19AA1ABA1BCA18C199D09AE0910
+:10260000BF09BBCE45E050E04093273950932839AD
+:10261000E2CE9801A3E0B0E00E946D19C616D7067D
+:10262000E806F90608F0E3CE809127399091283921
+:102630002CA53DA54EA55FA5280F391F411D511D95
+:10264000DA01C901B695A795979587958C159D05D3
+:10265000AE05BF0508F0CBCEC701B601601B710BFC
+:102660008109910928E030E040E050E00E944019E3
+:10267000200F311F2093253930932639B8CE1BA562
+:10268000123009F0CDCE20926139CACE80915939ED
+:1026900090915A39A0915B39B0915C3988169906AE
+:1026A000AA06BB0608F435CF8091243992E09093B6
+:1026B000223990931239806C809324392ACF81304B
+:1026C000E1F480914D3990914E39A0914F39B091FC
+:1026D000503988169906AA06BB0608F41ACF8091CD
+:1026E00024391092233980923E3990923F39A0923A
+:1026F0004039B09241398260DFCF80913E3990916C
+:102700003F39A0914039B0914139805C904EAF4F34
+:10271000BF4F88169906AA06BB0608F4FACE409168
+:1027200025395091263970E060E0475E5F4F6F4F6A
+:102730007F4F440F551F661F771F80911539909169
+:1027400016398C0130E020E00417150726073707FB
+:1027500040F4CA01413D57406105710510F080ED1C
+:1027600097E060E00E94C901D4CE209113393091E6
+:1027700014392115310501F58091243940914239F0
+:102780005091433960914439709145397501640124
+:10279000C41AD50AE60AF70AB701A6014137514122
+:1027A0006140710508F4B5CE20E63AEE2093133966
+:1027B00030931439806480CF80913D39882309F4A7
+:1027C000A8CE8091423990914339A0914439B0917B
+:1027D0004539AC01BD01420F531F611D711D8A01B6
+:1027E0009B0140911B3950911C3970E060E0885189
+:1027F0009C4FAF4FBF4F6A017B01C80ED91EEA1E26
+:10280000FB1EC016D106E206F30630F4D901C8015A
+:10281000841B950BA60BB70B88169906AA06BB0658
+:1028200008F477CE60E080911539909116399ACFEF
+:1028300080914D3990914E39A0914F39B0915039D6
+:1028400088169906AA06BB0608F463CE809124393F
+:10285000C0902539D0902639F12CE12CE091273910
+:10286000F091283992E09093233982608093243943
+:1028700010924A3910926139CF01880F991F970140
+:102880008601080F191F211D311DD901C801883E7D
+:1028900013E09107A105B10520F488EE93E0A0E0D4
+:1028A000B0E0AC01BD01480D591D6A1D7B1D409370
+:1028B000513950935239609353397093543995017B
+:1028C000840104521A4F2F4F3F4F009355391093F4
+:1028D0005639209357393093583980914C398111AA
+:1028E00030C020901339309014392114310449F14B
+:1028F0008091423990914339A0914439B091453942
+:1029000088519C4FAF4FBF4F820D931DA11DB11D2C
+:10291000CE0EDF1EE11CF11CF694E794D794C79409
+:102920008C0D9D1DAE1DBF1D081719072A073B07FB
+:1029300040F48093553990935639A0935739B0930A
+:1029400058398091553990915639A0915739B091A5
+:10295000583984179507A607B70708F0DACD4093D2
+:102960005539509356396093573970935839D1CDB2
+:1029700080916139811142C08091513990915239D1
+:10298000A0915339B091543988159905AA05BB0512
+:1029900008F0BFCD80914C39882369F120915D39D1
+:1029A00030915E39A3E0B0E00E946D19DC01CB01EB
+:1029B000B695A7959795879581342FE19207A10544
+:1029C000B10520F080E49FE1A0E0B0E080935F39A2
+:1029D0009093603980916239909163390196809328
+:1029E00062399093633980912439846080932439CB
+:1029F0000E94BE028ECD0E942002ECCF80915539FC
+:102A000090915639A0915739B09158398816990646
+:102A1000AA06BB0608F47DCD80913D39882309F4D0
+:102A200078CD80914A39882309F44BC030914639DA
+:102A300020914739909148398091493930934239F2
+:102A4000209343399093443980934539F090243949
+:102A500080914C39811130C020915D3930915E39BF
+:102A60008091663990916739019680936639909389
+:102A700067392093193930931A3900911739109119
+:102A800018390837110510F408E710E0A5E5B0E0A3
+:102A90000E946D1924E630E040E050E00E941E19CB
+:102AA0000217130708F4890100931539109316399A
+:102AB0008F2D80658093243910924C3910922339E0
+:102AC00028CD382D292D9A2D8B2DB8CF8091EC381B
+:102AD0009091ED38A091EE38B091EF38481A590A2C
+:102AE0006A0A7B0A28EB42162BE052066104710445
+:102AF00010F40C94040C0C94FD0A8091EC38909125
+:102B0000ED38A091EE38B091EF38481A590A6A0AA8
+:102B10007B0A30ED431637E053066104710410F46C
+:102B20000C94040C0C94FD0A0E949504811112C0AF
+:102B300086E08093F0384092EC385092ED38609205
+:102B4000EE387092EF3880E00E94270597E090936E
+:102B5000C53816C00E9495048823B1F080910804FE
+:102B6000869581709091C13880FF10C091110C94AE
+:102B7000040C81E08093C13886E08093C53884E6F8
+:102B80008093C4380C948E0B80E0ECCF992339F0FD
+:102B90001092C13881E00E94270595E0D8CF80913E
+:102BA000EC389091ED38A091EE38B091EF38B30148
+:102BB000A201481B590B6A0B7B0BDB01CA01803158
+:102BC0009742A105B10510F40C94040C4092EC3826
+:102BD0005092ED386092EE387092EF38DBCF0E9461
+:102BE0009504882369F015E01093F0384092EC3892
+:102BF0005092ED386092EE387092EF380C94F20AF1
+:102C00008091EC389091ED38A091EE38B091EF388A
+:102C1000481A590A6A0A7B0A60ED461667E05606AA
+:102C20006104710410F40C94040C0C94FD0A2091BE
+:102C3000C438822F90E00C9669E170E00E94F6188B
+:102C4000162F611104C011E0211101C010E0C7016D
+:102C5000B60127E731E040E050E00E941E1929014B
+:102C60003A019B01AC01812F90E0B0E0A0E0481652
+:102C700059066A067B0608F48AC060E00E943C059B
+:102C800028E0B8EBCB16BBE0DB06E104F10478F4F6
+:102C9000C701B60153E0660F771F881F991F5A9529
+:102CA000D1F728EB3BE040E050E00E941E19209352
+:102CB000DB38E6E02E9FC00111246AE070E00E943C
+:102CC0000A196093DC382093DD380BED18E3512CA2
+:102CD000412CD8018D918D01480E511CAA16BB06BE
+:102CE000C1F7B7EB4B16B3E05B06D8F08BEDE82EDF
+:102CF00088E3F82E712C612CF70121917F016F017F
+:102D0000F1E0CF1AD10830E0A6EBB3E00E946D19D4
+:102D1000A30192010E941E19D6012C93E016F10620
+:102D200059F7ACECB8E3EBEDF8E390E020E08191EB
+:102D3000811121E03D91831391E0E017F107B9F78C
+:102D40008091CB382111D3C18823B9F08091CA3842
+:102D50008F5F8093CA38843080F080E280932604AD
+:102D600080E1809325041092CB38ECECF8E31192CB
+:102D7000CDA0DEA0CE16DF06D1F78091CB38882318
+:102D800011F40C94970A9111B6C10C94970AAFEF05
+:102D9000B0E00E94831927E731E040E050E00E9454
+:102DA0001E19622F842D6ACFC701B60120EA3FE0C9
+:102DB00040E050E00E941E199B01AC01203DF7E06D
+:102DC0003F074105510550F060EA7FE080E090E068
+:102DD000621B730B840B950B9B01AC010FEF10E092
+:102DE000AFEFB0E00E94831920ED37E040E050E003
+:102DF0000E941E19229FC001239F900D900D112447
+:102E0000B8010E94F61825E1269FC001279F900D6A
+:102E10001124B8010E94F61813E0160F8091C338F0
+:102E2000882319F0812F8695180F8091C238882346
+:102E300059F0C701B60128E230E040E050E00E94BE
+:102E40001E19110F20FF10E01093DB3876E0179F5A
+:102E5000C00111246AE070E00E940A196093DC3816
+:102E60001093DD3832CF20E084ECC81689E0D80614
+:102E7000E104F10468F4AFEFB0E0A70196010E940D
+:102E8000831924EC39E040E050E00E941E1920959F
+:102E9000229FC00111242FEF30E0B9010E94F618E3
+:102EA000FB01FF27AEE16A9FC0011124B9010E9416
+:102EB000F6186093DB3848E24E9FC0014F9F900D9B
+:102EC0001124B9010E94F6186093DC38CF0144E068
+:102ED000880F991F4A95E1F7B9010E94F61860938F
+:102EE000DD38F3CEC701B6012AEF30E040E050E014
+:102EF0000E941E1920FDE9CE1092DB3888E28093F3
+:102F0000DC381092DD381092DE388EE18093DF38A5
+:102F10001092E038DACE8091C43869E10E94EA1854
+:102F2000782EB3E060E084E0B71508F4DDC0872DAB
+:102F30000E943C05C701B6012CED35E040E050E0B1
+:102F40000E941E199B01AC012E3EE2E03E074105A6
+:102F5000510560F09CEDC92E95E0D92EE12CF12CA5
+:102F6000C21AD30AE40AF50AA70196010FEF10E08E
+:102F7000AFEFB0E00E9483192EEE32E040E050E067
+:102F80000E941E19229FC001239F900D900D1124B5
+:102F9000B8010E94F6189B01739443E0749EF001FF
+:102FA0001124E552F74C44E2469FC001479F900D23
+:102FB0001124B8010E94F6186C5F6083118246E00C
+:102FC000429FC001439F900D1124B8010E94F61842
+:102FD00062837BCE58E8C51653E1D506E104F104BF
+:102FE000D0F488E28093DE381092DF381092E03817
+:102FF0008093E1381092E2381092E3388093E438FD
+:103000001092E5381092E6388093E7381092E8384D
+:103010001092E9385ACEC701B60168587341810948
+:10302000910920E137E240E050E00E941E199B0127
+:10303000AC01283562E036074105510508F045CE60
+:103040002C3271E037074105510550F088E592E0D8
+:10305000A0E0B0E0821B930BA40BB50B9C01AD016B
+:103060000FEF10E0AFEFB0E00E9483192CE231E0E7
+:1030700040E050E00E941E19229FC001239F900D46
+:10308000900D1124B8010E94F6182CE0269FC00173
+:10309000279F900D1124B8010E94F6186093DE3826
+:1030A0001092DF381092E0386093E1381092E238E5
+:1030B0001092E3386093E4381092E5381092E638C5
+:1030C0006093E738A3CF2091C438822F90E00C960C
+:1030D00069E170E00E94F618862F611104C081E05A
+:1030E000211101C080E060E00E943C05EECD10920D
+:1030F000CA38992351F18091CB3881110CC080E3FB
+:103100008093260481E08093CB3881EE94E0019790
+:10311000F1F700C000000E94AA05882311F40C9466
+:10312000970A81E00E9491050E94AA05811104C0BE
+:103130008091EA388823C1F38091EA38882349F0E6
+:1031400080E00E9491050C94970A8823A1F20C94C8
+:10315000970AF89498ED21E030E094BF2093610045
+:10316000EBEDF8E38FE0219138E02D9A000027FF86
+:103170002D98220F000000002D98000000003A95C5
+:10318000A1F78A9581F723E030E094BF2093610096
+:10319000789480E00E949105ECECF8E3D4018D91E5
+:1031A0004D018193A017B107C9F70C94970A80E0ED
+:1031B0000C94A10ADB018F939F930E948319BF9106
+:1031C000AF91A29F800D911DA39F900DB29F900D76
+:1031D00011240895991B79E004C0991F961708F0EF
+:1031E000961B881F7A95C9F780950895AA1BBB1B6B
+:1031F00051E107C0AA1FBB1FA617B70710F0A61BF7
+:10320000B70B881F991F5A95A9F780959095BC0117
+:10321000CD01089597FB072E16F4009407D077FD93
+:1032200009D00E94F61807FC05D03EF490958195D0
+:103230009F4F0895709561957F4F0895A1E21A2ED2
+:10324000AA1BBB1BFD010DC0AA1FBB1FEE1FFF1F4A
+:10325000A217B307E407F50720F0A21BB30BE40B9A
+:10326000F50B661F771F881F991F1A9469F76095E1
+:103270007095809590959B01AC01BD01CF0108959B
+:10328000052E97FB1EF400940E94571957FD07D096
+:103290000E941E1907FC03D04EF40C945719509548
+:1032A0004095309521953F4F4F4F5F4F0895909532
+:1032B0008095709561957F4F8F4F9F4F0895EE0FCA
+:1032C000FF1F0590F491E02D09940E946D1933239E
+:1032D00012F48A1B9B0B0C947E19A29FB001B39F22
+:1032E000C001A39F700D811D1124911DB29F700D0F
+:1032F000811D1124911D08950E946D19B7FF089535
+:10330000821B930B08950E946D19A59F900DB49F89
+:10331000900DA49F800D911D11240895F894FFCF66
+:1033200000000000E8030301020007101A252F3AED
+:10333000444F5A647A0D240E740EA60ECE0EEC0E77
+:0A3340001E0F500FA00F04106810BC
+:02334A00190068
+:00000001FF
firmware/src/accel.cadded+182
@@ -0,0 +1,182 @@+/*+ * accel.c : LIS2DW12 driver (TWI0 host, polled, with timeouts)+ *+ * Register facts used (LIS2DW12 DocID029682 Rev 4):+ *   WHO_AM_I 0x0F = 0x44+ *   CTRL1 0x20: ODR[7:4] MODE[3:2] LP_MODE[1:0]; 0x20 = 12.5 Hz LP mode 1, 0x40 = 50 Hz LP mode 1+ *   CTRL2 0x21: SOFT_RESET bit 6, BDU bit 3, IF_ADD_INC bit 2+ *   CTRL3 0x22: LIR bit 4 (latched interrupts), push-pull active high by default+ *   CTRL4_INT1_PAD_CTRL 0x23: INT1_WU bit 5+ *   CTRL6 0x25: FS[5:4] = 00 -> +/-2 g, LP mode 1 is 12-bit: 0.976 mg / LSB+ *   OUT_X_L 0x28 .. OUT_Z_H 0x2D (left aligned)+ *   WAKE_UP_THS 0x34: WK_THS[5:0], 1 LSB = FS / 64 = 31.25 mg at +/-2 g+ *   WAKE_UP_DUR 0x35, WAKE_UP_SRC 0x38, ALL_INT_SRC 0x3B+ *   CTRL7 0x3F: INTERRUPTS_ENABLE bit 5+ */+#include <avr/io.h>+#include "board.h"+#include "accel.h"+#include "power.h"++#define R_WHO_AM_I    0x0F+#define R_CTRL1       0x20+#define R_CTRL2       0x21+#define R_CTRL3       0x22+#define R_CTRL4       0x23+#define R_CTRL6       0x25+#define R_OUT_X_L     0x28+#define R_WAKE_THS    0x34+#define R_WAKE_DUR    0x35+#define R_WAKE_SRC    0x38+#define R_ALL_INT_SRC 0x3B+#define R_CTRL7       0x3F++#define TWI_TIMEOUT   4000u++/* --------------------------------------------------------------- TWI host */+static void twi_on(void)+{+    /* MBAUD = f_PER / (2 f_SCL) - 5 (rise time neglected): 5 MHz, 100 kHz -> 20 */+    TWI0.MBAUD = (uint8_t)(F_CPU / (2UL * 100000UL) - 5);+    TWI0.MCTRLA = TWI_ENABLE_bm;+    TWI0.MSTATUS = TWI_BUSSTATE_IDLE_gc;+}++void twi_off(void)+{+    TWI0.MCTRLA = 0;+}++static bool twi_wait(uint8_t flag)+{+    uint16_t n = TWI_TIMEOUT;+    while (!(TWI0.MSTATUS & flag)) {+        if (TWI0.MSTATUS & (TWI_BUSERR_bm | TWI_ARBLOST_bm)) return false;+        if (!--n) return false;+    }+    return true;+}++static void twi_stop(void)+{+    TWI0.MCTRLB = TWI_MCMD_STOP_gc;+}++static bool twi_start(uint8_t addr_rw)+{+    TWI0.MADDR = addr_rw;+    if (!twi_wait(TWI_WIF_bm | TWI_RIF_bm)) { twi_stop(); TWI0.MSTATUS = TWI_BUSSTATE_IDLE_gc; return false; }+    if (TWI0.MSTATUS & TWI_RXACK_bm) { twi_stop(); return false; }      /* NACK */+    return true;+}++static bool reg_write(uint8_t reg, uint8_t val)+{+    if (!twi_start((uint8_t)(ACC_I2C_ADDR << 1))) return false;+    TWI0.MDATA = reg;+    if (!twi_wait(TWI_WIF_bm)) { twi_stop(); return false; }+    TWI0.MDATA = val;+    if (!twi_wait(TWI_WIF_bm)) { twi_stop(); return false; }+    twi_stop();+    return true;+}++static bool reg_read(uint8_t reg, uint8_t *buf, uint8_t n)+{+    if (!twi_start((uint8_t)(ACC_I2C_ADDR << 1))) return false;+    TWI0.MDATA = reg;+    if (!twi_wait(TWI_WIF_bm)) { twi_stop(); return false; }+    if (!twi_start((uint8_t)((ACC_I2C_ADDR << 1) | 1))) return false;   /* repeated start */+    for (uint8_t i = 0; i < n; i++) {+        if (i && !twi_wait(TWI_RIF_bm)) { twi_stop(); return false; }+        buf[i] = TWI0.MDATA;+        TWI0.MCTRLB = (i + 1 == n) ? (TWI_ACKACT_NACK_gc | TWI_MCMD_STOP_gc) : TWI_MCMD_RECVTRANS_gc;+    }+    return true;+}++/* ------------------------------------------------------------- LIS2DW12 */+bool acc_init(void)+{+    uint8_t id = 0;+    twi_on();+    if (!reg_read(R_WHO_AM_I, &id, 1) || id != 0x44) return false;+    reg_write(R_CTRL2, 0x40);              /* soft reset */+    delay_ms_busy(5);+    reg_write(R_CTRL2, 0x0C);              /* BDU, IF_ADD_INC */+    reg_write(R_CTRL6, 0x00);              /* +/-2 g, ODR/2 bandwidth, low-pass path */+    reg_write(R_CTRL3, 0x10);              /* LIR: INT1 stays high until the source is read */+    reg_write(R_WAKE_DUR, 0x00);           /* 1 ODR sample over threshold */+    reg_write(R_CTRL7, 0x20);              /* INTERRUPTS_ENABLE */+    reg_write(R_CTRL4, 0x20);              /* wake-up -> INT1 */+    return true;+}++/* Sleep: LP mode 1 at 12.5 Hz (~1 uA). The wake-up function compares the+ * high-pass (slope) filtered acceleration with 2 LSB = 62.5 mg, so a gentle+ * pick-up or a nudge wakes the MCU through PA2. */+void acc_mode_sleep(void)+{+    twi_on();+    reg_write(R_CTRL1, 0x20);+    reg_write(R_WAKE_THS, 2);+    reg_write(R_CTRL4, 0x20);+    acc_clear_int();+}++/* Awake / session: 50 Hz LP mode 1, wake-up threshold in mg (0.25 g in a+ * session = 8 LSB: any jolt of a pick-up raises INT1 at once). */+void acc_mode_watch(uint16_t wake_mg)+{+    uint16_t lsb = (uint16_t)((wake_mg * 64u + 1000u) / 2000u);+    if (lsb < 1) lsb = 1;+    if (lsb > 63) lsb = 63;+    twi_on();+    reg_write(R_CTRL1, 0x40);+    reg_write(R_WAKE_THS, (uint8_t)lsb);+    reg_write(R_CTRL4, 0x20);+    acc_clear_int();+}++void acc_clear_int(void)+{+    uint8_t s;+    reg_read(R_WAKE_SRC, &s, 1);+    reg_read(R_ALL_INT_SRC, &s, 1);+}++bool acc_read(vec_mg_t *v)+{+    uint8_t b[6];+    if (!reg_read(R_OUT_X_L, b, 6)) return false;+    int16_t r[3];+    for (uint8_t i = 0; i < 3; i++) {+        int16_t raw = (int16_t)((uint16_t)b[2 * i] | ((uint16_t)b[2 * i + 1] << 8));+        raw >>= 4;                                         /* 12-bit, 0.976 mg/LSB */+        r[i] = (int16_t)(((int32_t)raw * 250) >> 8);       /* * 0.977 -> mg */+    }+    v->x = r[0]; v->y = r[1]; v->z = r[2];+    return true;+}++/* ------------------------------------------------------------- geometry */+uint32_t vec_dist2(const vec_mg_t *a, const vec_mg_t *b)+{+    int32_t dx = (int32_t)a->x - b->x, dy = (int32_t)a->y - b->y, dz = (int32_t)a->z - b->z;+    return (uint32_t)(dx * dx + dy * dy + dz * dz);+}++/* angle(a, b) > 30 deg  <=>  dot < cos(30) |a||b|  <=>  dot <= 0 or 4 dot^2 < 3 |a|^2 |b|^2.+ * Vectors are scaled to 32 mg units first so everything fits in 32 bits+ * (|v| <= ~110 units at 2 g per axis). */+bool vec_tilt_over_30(const vec_mg_t *a, const vec_mg_t *b)+{+    int32_t ax = a->x / 32, ay = a->y / 32, az = a->z / 32;+    int32_t bx = b->x / 32, by = b->y / 32, bz = b->z / 32;+    int32_t dot = ax * bx + ay * by + az * bz;+    if (dot <= 0) return true;+    uint32_t na = (uint32_t)(ax * ax + ay * ay + az * az);+    uint32_t nb = (uint32_t)(bx * bx + by * by + bz * bz);+    if (!na || !nb) return true;                           /* free fall: treat as tipped */+    return 4u * (uint32_t)dot * (uint32_t)dot < 3u * na * nb;+}
firmware/src/accel.hadded+23
@@ -0,0 +1,23 @@+/*+ * accel.h : LIS2DW12 over TWI0 (PB0 SCL, PB1 SDA, address 0x19)+ */+#ifndef ACCEL_H+#define ACCEL_H++#include <stdint.h>+#include <stdbool.h>++typedef struct { int16_t x, y, z; } vec_mg_t;++bool acc_init(void);                 /* TWI on, WHO_AM_I check, base configuration */+void acc_mode_sleep(void);           /* LP mode 1, 12.5 Hz, wake-up on 62.5 mg to INT1, latched */+void acc_mode_watch(uint16_t wake_mg);  /* LP mode 1, 50 Hz, wake-up threshold to INT1 */+bool acc_read(vec_mg_t *v);          /* latest sample in mg */+void acc_clear_int(void);            /* read the sources: releases the latched INT1 */+void twi_off(void);                  /* TWI disabled, pins released (external pull-ups) */++/* geometry helpers (pure integer math) */+bool vec_tilt_over_30(const vec_mg_t *a, const vec_mg_t *b);   /* angle(a,b) > 30 deg */+uint32_t vec_dist2(const vec_mg_t *a, const vec_mg_t *b);                      /* |a-b|^2 in mg^2 */++#endif
firmware/src/board.hadded+116
@@ -0,0 +1,116 @@+/*+ * board.h : Ghost Hand rev A pin map and board constants+ *+ * MCU: ATtiny1616-MNR (tinyAVR 1-series, VQFN-20), 3.3 V from a TPS7A0233 LDO.+ * Source of truth: design/netlist.json (U1) and design/calcs.json.+ *+ *  VQFN pin | Port | Net            | Function used by the firmware+ *  ---------+------+----------------+--------------------------------------------+ *     19    | PA0  | UPDI           | programming only+ *     20    | PA1  | CHG_STAT       | input, MCP73831 STAT via 68k/100k (low = charging)+ *      1    | PA2  | ACC_INT1       | input, LIS2DW12 INT1, fully async pin (wakes power-down)+ *      2    | PA3  | IR940_G        | TCB1 WO (default PORTMUX), 2 x 940 nm emitters+ *      5    | PA4  | RX_PWR         | GPIO high = TLV9062 receiver and RX_REF bias on+ *      6    | PA5  | IR850_G        | TCB0 WO (default PORTMUX), 850 nm emitter+ *      7    | PA6  | VBAT_SENSE     | ADC0 AIN6, VBAT/2 from an always-on 4.7M/4.7M divider+ *      8    | PA7  | RX_SIG         | AC0 AINP0 (receiver output)+ *     14    | PB0  | SCL            | TWI0 SCL (default pins), 4.7k pull-up+ *     13    | PB1  | SDA            | TWI0 SDA (default pins), 4.7k pull-up+ *     12    | PB2  | VBUS_SENSE     | input, 5 V USB -> 3.0 V, fully async pin+ *     11    | PB3  | (unconnected)  | input with pull-up (was VBAT_SENSE_EN on the first draft)+ *     10    | PB4  | LED_EN_N       | output, low = WS2812 rail on (P-FET Q3)+ *      9    | PB5  | LED_DIN        | output, WS2812B-2020 data (bit-banged)+ *     15    | PC0  | ACC_INT2       | input, unused by this firmware+ *     16    | PC1  | (unused)       | input with pull-up+ *     17    | PC2  | ECHO_EVT       | EVOUT2 debug output (AC0 event) during a session+ *     18    | PC3  | (unused)       | input with pull-up+ */+#ifndef BOARD_H+#define BOARD_H++#ifndef F_CPU+#define F_CPU 5000000UL            /* OSC20M (20 MHz fuse) / 4 */+#endif++/* ---- PORTA ---- */+#define PIN_CHG_STAT      1        /* PA1 */+#define PIN_ACC_INT1      2        /* PA2 */+#define PIN_IR940_G       3        /* PA3, TCB1 WO */+#define PIN_RX_PWR        4        /* PA4 */+#define PIN_IR850_G       5        /* PA5, TCB0 WO */+#define PIN_VBAT_SENSE    6        /* PA6, ADC0 AIN6 */+#define PIN_RX_SIG        7        /* PA7, AC0 AINP0 */++/* ---- PORTB ---- */+#define PIN_SCL           0        /* PB0 */+#define PIN_SDA           1        /* PB1 */+#define PIN_VBUS_SENSE    2        /* PB2 */+#define PIN_PB3_UNUSED    3        /* PB3, no longer connected */+#define PIN_LED_EN_N      4        /* PB4 */+#define PIN_LED_DIN       5        /* PB5 */++/* ---- PORTC ---- */+#define PIN_ACC_INT2      0        /* PC0 */+#define PIN_PC1_UNUSED    1+#define PIN_ECHO_EVT      2        /* PC2, EVOUT2 */+#define PIN_PC3_UNUSED    3++/* ---- LEDs ---- */+#define LED_COUNT         5        /* chain: 0 = logo, 1..4 = gauge 25/50/75/100 % */+#define LED_LOGO          0+#define LED_GAUGE0        1++/* ---- Accelerometer ---- */+#define ACC_I2C_ADDR      0x19     /* LIS2DW12, SA0 tied high */++/*+ * Resting orientation of the device in accelerometer axes (unit vector * 1000 mg).+ * The case stands the board so that +Z points up when it sits on the counter.+ * If the case puts the board on edge, change this (e.g. X = -1000 for "board+ * vertical, IR window facing the faucet"). Only used for the "level-ish" check+ * before a session starts; pick-up and tilt are measured against the actual+ * orientation captured at set-down.+ */+#define REST_X_MG         0+#define REST_Y_MG         0+#define REST_Z_MG         1000++/* ---- Timing and thresholds (calcs.json "timing") ---- */+#define SESSION_CAP_MS        600000UL   /* hard 10 minute cap */+#define SETTLE_STILL_MS       1500U      /* still and level for 1.5 s -> session */+#define SETTLE_GIVEUP_MS      15000U     /* no set-down within 15 s -> sleep */+#define GAUGE_SHOW_MS         3000U      /* charge shown at session start */+#define LISTEN_MS             2000U      /* listen with echo disabled */+#define LISTEN_MAX_MS         5000U      /* extend if the faucet pulses slowly */+#define STILL_TOL_MG          60         /* still = within 60 mg of the anchor sample */+#define PICKUP_DELTA_MG       250        /* 0.25 g change vs set-down vector -> stop */+#define TILT_LIMIT_DEG        30         /* > 30 deg from rest -> stop / not level */+#define LOW_BATT_MV           3450U      /* stop under load below this */+#define NO_LIGHT_BATT_MV      3330U      /* below this, do not even light the red LED */+#define BATT_CHECK_MS         10000U     /* battery re-check period in a session */++/* ---- Echo limits (calcs.json "echo") ---- */+#define ECHO_EXTRA_US         5          /* echo = faucet pulse + 5 us */+#define ECHO_MIN_US           8+#define ECHO_MAX_US           40         /* emitter limit: <= 40 us */+#define ECHO_DEFAULT_US       25         /* used when no width was measured */+#define ECHO_BLANK_US         150        /* comparator blanking after each echo */+#define ECHO_DUTY_WINDOW_MS   10         /* duty guard window */+#define ECHO_DUTY_MAX_US      200        /* <= 2 % of 10 ms */+#define EMITTER_ROTATE_MS     300        /* 850 / 940 / both rotation ("moving hand") */+#define PULSE_GROUP_MS        3          /* pulses closer than this = one interrogation */++/* ---- Receiver threshold (DAC0 against VREF 1.1 V) ---- */+#define RX_REF_MV             300        /* nominal RX_REF bias */+#define RX_THRESH_OVER_MV     60         /* threshold = noise floor + 60 mV */+#define RX_THRESH_MIN_MV      330+#define RX_THRESH_MAX_MV      1000+#define RX_SETTLE_MS          60         /* RX_REF: 1 uF with ~9 k -> 9 ms tau */++/* ---- LED current cap ----+ * WS2812B-2020-V6: 12 mA per colour channel at full scale. The rail budget is+ * 60 mA on the LDO; MCU + receiver + accel take about 4 mA, so the LEDs get at+ * most ~45 mA = 950 / 255 * 12 mA (sum of all 15 channel values <= 950). */+#define LED_SUM_MAX           950++#endif
firmware/src/echo.cadded+294
@@ -0,0 +1,294 @@+/*+ * echo.c : hardware IR transponder+ *+ * Signal path (no CPU in the loop):+ *+ *   RX_SIG (PA7) --> AC0 AINP0+ *   DAC0 (VREF 1.1 V) --> AC0 negative input (MUXNEG = DAC)+ *   AC0 OUT --> EVSYS ASYNCCH0 --+--> ASYNCUSER0  (TCB0 capture/start)+ *                                +--> ASYNCUSER11 (TCB1 capture/start)+ *                                +--> ASYNCUSER10 (EVOUT2 = PC2, ECHO_EVT probe)+ *   TCB0 single-shot, ASYNC = 1: WO (PA5) goes high on the event edge itself,+ *   TCB1 likewise on PA3. Both count CCMP cycles of CLK_PER (5 MHz) then drop.+ *+ * Datasheet facts this relies on (ATtiny1614/16/17 DS40002204A):+ *   - TCB single-shot with CTRLB.ASYNC = 1: "the output is high when the event+ *     arrives", counter starts 2 to 3 CLK_TCB later. Echo latency is therefore+ *     the comparator delay plus the event routing, well under 1 us.+ *   - Single-shot ignores events while counting, so our own echo (which our+ *     photodiode also sees) cannot retrigger during the echo. After the echo+ *     the event users are disconnected for ECHO_BLANK_US.+ *   - Writing TOP to CNT before enabling stops the single-shot firing at enable.+ *   - Sleep Mode Activity table: in STANDBY only RTC, ADC, TCBn (with RUNSTDBY)+ *     keep CLK_PER, and the wake sources include "TCBn Capture interrupt" but+ *     NOT the AC interrupt. So pulses are timestamped from the TCB0 CAPT+ *     interrupt (end of capture or end of echo), and AC0 interrupts are unused.+ *   - TCA0 has no RUNSTDBY on the 1-series, so the blanking timer (TCA0) only+ *     runs in IDLE: main.c sleeps in IDLE while echo_blanking() is true+ *     (about 0.2 ms per faucet pulse) and in STANDBY otherwise.+ *   - PA5 is also AC0 OUT: AC0.CTRLA.OUTEN must stay 0.+ *   - The 1-series has no "DACREF" (that is the 2-series / AVR Dx name); the+ *     equivalent here is DAC0 with VREF.CTRLA.DAC0REFSEL, output buffer off.+ *     DAC0 OUT would be PA6 (VBAT_SENSE): OUTEN stays 0 too.+ */+#include <avr/io.h>+#include <avr/interrupt.h>+#include <util/atomic.h>+#include "board.h"+#include "echo.h"+#include "power.h"++#define CH0             EVSYS_ASYNCUSER0_ASYNCCH0_gc   /* 0x03 = async channel 0, same code for every user */+#define TCA_TICK_NS     3200u                          /* CLK_PER / 16 at 5 MHz */+#define US_TO_TCA(us)   ((uint16_t)(((uint32_t)(us) * 1000u + TCA_TICK_NS - 1) / TCA_TICK_NS))++#define QLEN 16+static volatile echo_pulse_t q[QLEN];+static volatile uint8_t q_head, q_tail;+static volatile bool listening;+static volatile bool blanking;+static volatile bool held;+static uint8_t width_us = ECHO_DEFAULT_US;+static volatile uint16_t blank_ticks;+static volatile uint32_t duty_win_t;+static volatile uint16_t duty_win_us;++static void push(uint32_t t, uint16_t w)+{+    uint8_t n = (uint8_t)((q_head + 1) & (QLEN - 1));+    if (n == q_tail) return;                 /* full: drop (main loop is late) */+    q[q_head].t = t;+    q[q_head].width_cyc = w;+    q_head = n;+}++bool echo_pop(echo_pulse_t *p)+{+    bool ok = false;+    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {+        if (q_tail != q_head) {+            p->t = q[q_tail].t;+            p->width_cyc = q[q_tail].width_cyc;+            q_tail = (uint8_t)((q_tail + 1) & (QLEN - 1));+            ok = true;+        }+    }+    return ok;+}++static void users_connect(bool on)+{+    EVSYS.ASYNCUSER0 = on ? CH0 : 0;         /* TCB0 */+    EVSYS.ASYNCUSER11 = on ? CH0 : 0;        /* TCB1 */+}++void echo_init(void)+{+    /* gates low with the pins as inputs: the 100k pull-downs hold the FETs off */+    PORTA.OUTCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G) | (1 << PIN_RX_PWR);+    PORTA.DIRCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    PORTA.DIRSET = (1 << PIN_RX_PWR);+    PORTA.PIN7CTRL = PORT_ISC_INPUT_DISABLE_gc;        /* analog input */+    TCB0.CTRLA = 0; TCB1.CTRLA = 0;+    AC0.CTRLA = 0; DAC0.CTRLA = 0;+    users_connect(false);+}++void echo_rx_on(void)+{+    PORTA.OUTSET = (1 << PIN_RX_PWR);                  /* TLV9062 + RX_REF bias */+    VREF.CTRLA = (uint8_t)((VREF.CTRLA & ~VREF_DAC0REFSEL_gm) | VREF_DAC0REFSEL_1V1_gc);+    VREF.CTRLB |= VREF_DAC0REFEN_bm;+    DAC0.DATA = (uint8_t)(((uint32_t)(RX_REF_MV + RX_THRESH_OVER_MV) * 256u) / 1100u);+    DAC0.CTRLA = DAC_ENABLE_bm | DAC_RUNSTDBY_bm;      /* OUTEN = 0: internal only */+    AC0.MUXCTRLA = AC_MUXPOS_PIN0_gc | AC_MUXNEG_DAC_gc;+    AC0.INTCTRL = 0;+    AC0.CTRLA = AC_ENABLE_bm | AC_HYSMODE_25mV_gc | AC_RUNSTDBY_bm;   /* fast mode, OUTEN = 0 */+    EVSYS.ASYNCCH0 = EVSYS_ASYNCCH0_AC0_OUT_gc;+    EVSYS.ASYNCUSER10 = EVSYS_ASYNCUSER10_ASYNCCH0_gc; /* EVOUT2 */+    PORTC.OUTCLR = (1 << PIN_ECHO_EVT);+    PORTC.DIRSET = (1 << PIN_ECHO_EVT);+    PORTMUX.CTRLA |= PORTMUX_EVOUT2_bm;+    held = false;+}++void echo_rx_off(void)+{+    cli();+    users_connect(false);+    TCB0.CTRLA = 0; TCB0.INTCTRL = 0; TCB0.CTRLB = 0;+    TCB1.CTRLA = 0; TCB1.CTRLB = 0;+    TCA0.SINGLE.CTRLA = 0; TCA0.SINGLE.INTCTRL = 0;+    blanking = false;+    listening = false;+    sei();+    PORTA.OUTCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    PORTA.DIRCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    PORTMUX.CTRLA &= (uint8_t)~PORTMUX_EVOUT2_bm;+    EVSYS.ASYNCUSER10 = 0;+    EVSYS.ASYNCCH0 = 0;+    PORTC.OUTCLR = (1 << PIN_ECHO_EVT);+    AC0.CTRLA = 0;+    DAC0.CTRLA = 0;+    VREF.CTRLB &= (uint8_t)~VREF_DAC0REFEN_bm;+    PORTA.OUTCLR = (1 << PIN_RX_PWR);+    q_head = q_tail = 0;+}++/*+ * Threshold from the receiver's own noise floor: 16 ADC readings of RX_SIG+ * (1.1 V reference), take the 75th percentile (a stray faucet pulse in the+ * sample set cannot drag it up), add 60 mV, clamp.+ */+uint16_t echo_calibrate(void)+{+    uint16_t v[16];+    for (uint8_t i = 0; i < 16; i++) {+        v[i] = adc_read_mv(ADC_MUXPOS_AIN7_gc, false, 0, 2);+        delay_ms_busy(1);+    }+    for (uint8_t i = 1; i < 16; i++) {+        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;+    }+    uint16_t th = (uint16_t)(v[12] + RX_THRESH_OVER_MV);+    if (th < RX_THRESH_MIN_MV) th = RX_THRESH_MIN_MV;+    if (th > RX_THRESH_MAX_MV) th = RX_THRESH_MAX_MV;+    DAC0.DATA = (uint8_t)(((uint32_t)th * 256u) / 1100u);   /* 4.3 mV per step */+    return th;+}++/* Listen: TCB0 in pulse-width capture (EDGE = 0: rising edge clears and+ * restarts, falling edge captures and interrupts). Emitter pins are inputs, so+ * nothing is emitted. */+void echo_listen_start(void)+{+    cli();+    users_connect(false);+    TCB1.CTRLA = 0;+    TCB0.CTRLA = 0;+    PORTA.DIRCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    TCB0.CTRLB = TCB_CNTMODE_PW_gc;+    TCB0.EVCTRL = TCB_CAPTEI_bm;+    TCB0.INTFLAGS = TCB_CAPT_bm;+    TCB0.INTCTRL = TCB_CAPT_bm;+    TCB0.CTRLA = TCB_CLKSEL_CLKDIV1_gc | TCB_RUNSTDBY_bm | TCB_ENABLE_bm;+    listening = true;+    q_head = q_tail = 0;+    users_connect(true);+    sei();+}++static void tcb_single(TCB_t *t, uint16_t top, bool irq)+{+    t->CTRLA = 0;+    t->CTRLB = TCB_ASYNC_bm | TCB_CCMPEN_bm | TCB_CNTMODE_SINGLE_gc;+    t->CCMP = top;+    t->CNT = top;                        /* no shot at enable (datasheet 21.3.3.1.7) */+    t->EVCTRL = TCB_CAPTEI_bm;           /* EDGE = 0: start on the rising edge */+    t->INTFLAGS = TCB_CAPT_bm;+    t->INTCTRL = irq ? TCB_CAPT_bm : 0;+    t->CTRLA = TCB_CLKSEL_CLKDIV1_gc | TCB_RUNSTDBY_bm | TCB_ENABLE_bm;+}++void echo_arm(uint8_t us)+{+    if (us < ECHO_MIN_US) us = ECHO_MIN_US;+    if (us > ECHO_MAX_US) us = ECHO_MAX_US;+    width_us = us;+    /* output is high from the event (async) to CNT == CCMP plus 2..3 cycles */+    uint16_t top = (uint16_t)(us * (F_CPU / 1000000UL) - 2u);+    blank_ticks = US_TO_TCA(ECHO_BLANK_US);+    cli();+    users_connect(false);+    listening = false;+    PORTA.OUTCLR = (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    tcb_single(&TCB0, top, true);        /* TCB0 CAPT = end of echo: timestamp + blanking */+    tcb_single(&TCB1, top, false);+    TCA0.SINGLE.CTRLA = 0;+    TCA0.SINGLE.CTRLB = 0;               /* normal mode */+    TCA0.SINGLE.INTFLAGS = TCA_SINGLE_OVF_bm;+    TCA0.SINGLE.INTCTRL = TCA_SINGLE_OVF_bm;+    blanking = false;+    q_head = q_tail = 0;+    duty_win_t = time_ticks();+    duty_win_us = 0;+    users_connect(!held);+    sei();+}++/* The TCBs keep running on every faucet pulse (that is what timestamps them),+ * the pin direction decides whether an emitter actually fires: an input pin+ * leaves the gate on its 100k pull-down. */+void echo_set(bool enabled, uint8_t emitters)+{+    uint8_t on = 0;+    if (enabled) {+        if (emitters & EMIT_850) on |= (1 << PIN_IR850_G);+        if (emitters & EMIT_940) on |= (1 << PIN_IR940_G);+    }+    PORTA.DIRCLR = (uint8_t)(((1 << PIN_IR850_G) | (1 << PIN_IR940_G)) & ~on);+    PORTA.DIRSET = on;+}++bool echo_blanking(void) { return blanking; }++bool echo_quiet(void)+{+    return !blanking && !(TCB0.STATUS & TCB_RUN_bm) && !(TCB1.STATUS & TCB_RUN_bm);+}++void echo_hold(bool hold)+{+    cli();+    held = hold;+    if (hold) users_connect(false);+    else if (!blanking && (TCB0.CTRLA & TCB_ENABLE_bm)) users_connect(true);+    sei();+}++uint8_t echo_width_us(void) { return width_us; }++/* end of a capture (listen) or end of an echo (armed) */+ISR(TCB0_INT_vect)+{+    uint32_t now = time_ticks();+    if (listening) {+        push(now, TCB0.CCMP);            /* reading CCMP clears CAPT in capture modes */+        TCB0.INTFLAGS = TCB_CAPT_bm;+        return;+    }+    TCB0.INTFLAGS = TCB_CAPT_bm;+    users_connect(false);                /* blank: our own echo must not retrigger */+    push(now, 0);++    /* duty guard: at most ECHO_DUTY_MAX_US of echo per 10 ms window (2 %) */+    uint16_t bt = blank_ticks;+    if ((uint32_t)(now - duty_win_t) >= (ECHO_DUTY_WINDOW_MS * TICKS_PER_S) / 1000u) {+        duty_win_t = now;+        duty_win_us = 0;+    }+    duty_win_us += width_us;+    if (duty_win_us + width_us > ECHO_DUTY_MAX_US) bt = US_TO_TCA(ECHO_DUTY_WINDOW_MS * 1000u);++    TCA0.SINGLE.CNT = 0;+    TCA0.SINGLE.PER = bt;+    TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV16_gc | TCA_SINGLE_ENABLE_bm;+    blanking = true;+}++/* blanking over: reconnect, but never while the comparator is still high+ * (reconnecting on a high level would look like a fresh rising edge) */+ISR(TCA0_OVF_vect)+{+    TCA0.SINGLE.INTFLAGS = TCA_SINGLE_OVF_bm;+    if (AC0.STATUS & AC_STATE_bm) {+        TCA0.SINGLE.CNT = 0;+        TCA0.SINGLE.PER = US_TO_TCA(50);+        return;+    }+    TCA0.SINGLE.CTRLA = 0;+    blanking = false;+    if (!held) users_connect(true);+}
firmware/src/echo.hadded+35
@@ -0,0 +1,35 @@+/*+ * echo.h : the hardware IR transponder (AC0 -> EVSYS -> TCB0/TCB1 single-shot)+ */+#ifndef ECHO_H+#define ECHO_H++#include <stdint.h>+#include <stdbool.h>++#define EMIT_850   0x01      /* TCB0 WO, PA5 */+#define EMIT_940   0x02      /* TCB1 WO, PA3 */+#define EMIT_BOTH  (EMIT_850 | EMIT_940)++typedef struct {+    uint32_t t;              /* RTC ticks (1/1024 s) at the end of the pulse / echo */+    uint16_t width_cyc;      /* listen mode: faucet pulse width in CLK_PER cycles (0.2 us) */+} echo_pulse_t;++void     echo_init(void);              /* pins safe, peripherals off (call once at boot) */+void     echo_rx_on(void);             /* powers the receiver, AC0, DAC0, event channel, EVOUT2 */+void     echo_rx_off(void);            /* everything off, emitters off, back to the sleep state */+uint16_t echo_calibrate(void);         /* noise floor -> threshold; returns threshold mV */++void     echo_listen_start(void);      /* TCB0 pulse-width capture, emitters disabled */+void     echo_arm(uint8_t width_us);   /* TCB0/TCB1 single-shot echo, emitters per echo_set() */+void     echo_set(bool enabled, uint8_t emitters);++bool     echo_pop(echo_pulse_t *p);    /* next captured pulse, false if none */+bool     echo_blanking(void);          /* true while the comparator is blanked (sleep IDLE, not STANDBY) */+bool     echo_quiet(void);             /* no echo or blanking in progress: safe for an LED write */+void     echo_hold(bool hold);         /* disconnect the event users (around LED writes) */++uint8_t  echo_width_us(void);++#endif
firmware/src/learn.cadded+429
@@ -0,0 +1,429 @@+/*+ * 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;+}
firmware/src/learn.hadded+141
@@ -0,0 +1,141 @@+/*+ * learn.h : faucet timeout / blip estimator (pure C, no hardware)+ *+ * The learner is fed two things only:+ *   - the time of every faucet interrogation pulse (ms since session start),+ *   - a periodic tick (ms since session start),+ * and it answers one question: should the echo be enabled right now?+ *+ * Everything in here compiles with plain gcc so test/ can drive it with a+ * simulated faucet. See README.md "How the learning works" for the full story.+ */+#ifndef LEARN_H+#define LEARN_H++#include <stdint.h>+#include <stdbool.h>++/* ---- tunables (calcs.json "timing") ---- */+#define LEARN_BLIP_START_MS     600u+#define LEARN_BLIP_MIN_MS       120u+#define LEARN_BLIP_MAX_MS       2000u+#define LEARN_RECOVER_MAX_MS    8000u   /* recovery from a timed-out faucet */+#define LEARN_MARGIN_MS         1500u   /* re-arm at timeout - 1.5 s */+#define LEARN_MARGIN_STEP_MS    500u    /* "re-arm earlier" after a miss */+#define LEARN_MARGIN_MAX_MS     4000u+#define LEARN_FALLBACK_REARM_MS 8000u   /* no observable rate change */+#define LEARN_TIMEOUT_MIN_MS    3000u   /* observations shorter than this are not timeouts */+#define LEARN_TIMEOUT_MAX_MS    65000u  /* uint16 ms storage limit */+#define LEARN_TIMEOUT_ASSUME_MS 60000u  /* assumed when no drop was ever seen */+#define LEARN_NO_TIMEOUT_MS     70000u  /* echo this long with no drop -> assume 60 s */+#define LEARN_RETURN_MS         1000u   /* "returned to active within 1 s" */+#define LEARN_DEFAULT_IDLE_MS   1000u   /* idle period when nothing was heard */++/* Rate classification: active period below 60 % of the idle period means the+ * faucet visibly speeds up when it sees a user. */+#define LEARN_RATE_RATIO_NUM    3u+#define LEARN_RATE_RATIO_DEN    5u++#define LEARN_STORE_MAGIC       0x4748u /* "GH" */+#define LEARN_STORE_VERSION     1u++/* Persistent part, kept in EEPROM between sessions. */+typedef struct {+    uint16_t magic;+    uint8_t  version;+    uint8_t  observable;        /* 0 unknown, 1 rate change seen, 2 no rate change */+    uint16_t timeout_ms;        /* learned faucet timeout (EWMA), 0 = unknown */+    uint16_t blip_ms;           /* current blip length */+    uint16_t blip_floor_ms;     /* never shorten below this (0 = no miss seen yet) */+    uint16_t blip_last_ok_ms;   /* shortest blip that has worked */+    uint16_t margin_ms;         /* re-arm margin before the timeout */+    uint16_t idle_period_ms;    /* fingerprint of the faucet this was learned on */+    uint16_t active_period_ms;+    uint8_t  crc;               /* CRC-8 over the bytes before it */+} learn_store_t;++typedef enum {+    LEARN_MODE_CLASSIFY = 0,    /* echo on, waiting to see whether the rate changes */+    LEARN_MODE_TRACK,           /* rate change observable: learn timeout + blip */+    LEARN_MODE_FALLBACK         /* no observable rate change: re-arm every 8 s */+} learn_mode_t;++typedef enum {+    LEARN_PH_ECHO = 0,          /* echo on, faucet should be running */+    LEARN_PH_BLIP,              /* echo off for blip_ms (re-arm) */+    LEARN_PH_VERIFY             /* echo back on, checking the faucet stayed on */+} learn_phase_t;++/* Event bits returned by learn_take_events() (for the lights and the log). */+#define LEARN_EV_BLIP_START     0x01+#define LEARN_EV_BLIP_END       0x02+#define LEARN_EV_DROP           0x04    /* faucet fell back to idle while we echoed */+#define LEARN_EV_TIMEOUT_LEARNT 0x08+#define LEARN_EV_BLIP_OK        0x10+#define LEARN_EV_BLIP_MISS      0x20+#define LEARN_EV_STORE_DIRTY    0x40    /* caller should save learn_store() */+#define LEARN_EV_MODE           0x80    /* classification finished */++typedef struct {+    learn_store_t s;++    learn_mode_t  mode;+    learn_phase_t phase;+    uint8_t       events;++    uint16_t p_idle;            /* idle interrogation period (ms) */+    uint16_t p_act;             /* active period (ms), EWMA */++    uint32_t now;+    uint32_t last_pulse;        /* time of the last pulse */+    uint32_t last_iv;           /* last interval (ms) */+    uint32_t last_fast;         /* time of the last pulse that came at the active rate */+    uint32_t npulses;+    bool     faucet_active;     /* derived from the pulse rate */++    uint32_t t_arm;             /* echo (re)enabled, also fallback re-arm base */+    uint32_t t_act;             /* last confirmed activation (faucet timer start) */+    uint32_t t_act_cand;        /* activation candidate after a blip */+    bool     act_cand_set;+    bool     timeout_measured;  /* this session has a real observation */+    bool     recovering;        /* blip was a recovery after a drop */++    uint32_t t_phase_end;       /* end of blip */+    uint32_t t_return_by;       /* VERIFY: must be active again by then */+    uint32_t t_verify_end;      /* VERIFY: no drop until then -> success */+    uint32_t t_classify_end;+    uint16_t blip_cur;          /* length of the blip in progress */+    uint16_t recover_ms;        /* next recovery blip length */+    bool     seen_active_after; /* VERIFY: saw active rate after blip end */++    /* statistics for tests / debug */+    uint16_t n_drops, n_blips, n_ok, n_miss;+} learn_t;++/* ---- listen phase helper (echo disabled) ---- */+typedef struct {+    uint32_t last;+    uint16_t iv[8];+    uint8_t  n_iv;+    uint8_t  n_pulses;+} learn_listen_t;++void     learn_listen_init(learn_listen_t *l);+void     learn_listen_pulse(learn_listen_t *l, uint32_t t_ms);+uint16_t learn_listen_period(const learn_listen_t *l);   /* 0 if unknown */++/* ---- store helpers ---- */+void     learn_store_defaults(learn_store_t *s);+uint8_t  learn_store_crc(const learn_store_t *s);+bool     learn_store_valid(const learn_store_t *s);++/* ---- session ---- */+/* stored may be NULL or invalid; idle_period_ms is from the listen phase (0 = unknown) */+void     learn_begin(learn_t *L, const learn_store_t *stored, uint16_t idle_period_ms, uint32_t now);+void     learn_pulse(learn_t *L, uint32_t t_ms);+void     learn_tick(learn_t *L, uint32_t now);+bool     learn_echo_on(const learn_t *L);+uint8_t  learn_take_events(learn_t *L);+const learn_store_t *learn_store(learn_t *L);   /* refreshes the CRC */++#endif
firmware/src/leds.cadded+246
@@ -0,0 +1,246 @@+/*+ * leds.c : WS2812B-2020-V6 driver and light patterns+ *+ * Why a bit-bang at 10 MHz (and not SPI / USART-SPI / CCL):+ *   LED_DIN is PB5. On the ATtiny1616 PB5 carries CLKOUT, AIN8, AC0 AINP1 and+ *   TCA0 WO2 (alternate) only: no SPI MOSI, no USART TxD, no CCL LUT output.+ *   So no serial peripheral can drive it; a cycle-counted loop it is. At the+ *   normal 5 MHz a 1.25 us bit is 6.25 cycles, too few to place both edges,+ *   and 20 MHz needs 4.5 V. 10 MHz (OSC20M / 2) is inside the 0-10 MHz+ *   @ 2.7 V speed grade, giving 13 cycles per bit:+ *+ *     WS2812B-2020-V6 spec       this loop @ 10 MHz+ *     T0H 220..380 ns            3 cycles = 300 ns+ *     T1H 580 ns..1 us           7 cycles = 700 ns+ *     T0L 580 ns..1 us           10 cycles = 1000 ns+ *     T1L 580 ns..1 us           6 cycles = 600 ns+ *     bit >= 1.25 us             13 cycles = 1.3 us+ *     RES >= 280 us              frames are >= 15 ms apart+ *+ *   T0L sits at the top of its range; WS2812-class parts only latch on a low+ *   longer than RES, so a longer low between bits (the byte fetch adds about+ *   0.6 us after each 8th bit) is harmless. The CPU clock is raised only for+ *   the ~200 us write with interrupts off, and only when the echo is idle;+ *   the echo event users are disconnected during the write so the TCBs never+ *   run on the doubled clock (a faucet pulse in that window just misses one+ *   echo, which no faucet treats as the hand leaving).+ *+ * Current cap: 12 mA per channel at 255, sum of all channel values limited to+ * LED_SUM_MAX (950 -> ~45 mA) so the 3.3 V LDO stays under 60 mA in total.+ */+#include <avr/io.h>+#include <avr/interrupt.h>+#include "board.h"+#include "leds.h"+#include "echo.h"+#include "power.h"++static uint8_t px[LED_COUNT * 3];        /* G, R, B per LED (WS2812 order) */+static uint8_t last[LED_COUNT * 3];+static bool rail_on;+static uint8_t dark_frames;+static led_pattern_t pat;+static uint8_t pct;+static uint32_t t0;+static bool f_echo, f_blip;++static void ws_send(const uint8_t *p, uint8_t n)+{+    uint8_t b, bits;+    __asm__ __volatile__(+        "1:  ld   %[b], %a[p]+      \n\t"+        "    ldi  %[bits], 8        \n\t"+        "2:  sbi  %[port], %[pin]   \n\t"   /* c1  rising edge            */+        "    nop                    \n\t"   /* c2                         */+        "    sbrs %[b], 7           \n\t"   /* c3 (c3-c4 if bit is 1)     */+        "    cbi  %[port], %[pin]   \n\t"   /* c4  '0': low after 3 cyc   */+        "    lsl  %[b]              \n\t"   /* c5                         */+        "    nop                    \n\t"   /* c6                         */+        "    nop                    \n\t"   /* c7                         */+        "    cbi  %[port], %[pin]   \n\t"   /* c8  '1': low after 7 cyc   */+        "    nop                    \n\t"   /* c9                         */+        "    nop                    \n\t"   /* c10                        */+        "    dec  %[bits]           \n\t"   /* c11                        */+        "    brne 2b                \n\t"   /* c12-c13 -> next bit at c14 */+        "    dec  %[n]              \n\t"+        "    brne 1b                \n\t"+        : [b] "=&d" (b), [bits] "=&d" (bits), [p] "+e" (p), [n] "+r" (n)+        : [port] "I" (_SFR_IO_ADDR(VPORTB_OUT)), [pin] "I" (PIN_LED_DIN)+        : "memory");+}++void leds_init(void)+{+    PORTB.OUTSET = (1 << PIN_LED_EN_N);  /* rail off */+    PORTB.OUTCLR = (1 << PIN_LED_DIN);+    PORTB.DIRSET = (1 << PIN_LED_EN_N) | (1 << PIN_LED_DIN);+    rail_on = false;+    for (uint8_t i = 0; i < sizeof last; i++) last[i] = 0;+}++void leds_off(void)+{+    PORTB.OUTCLR = (1 << PIN_LED_DIN);   /* never drive data into an unpowered LED */+    PORTB.OUTSET = (1 << PIN_LED_EN_N);+    rail_on = false;+    pat = PAT_OFF;+    for (uint8_t i = 0; i < sizeof last; i++) last[i] = 0;+}++void leds_pattern(led_pattern_t p, uint8_t percent, uint32_t now)+{+    pat = p; pct = percent; t0 = now;+}++void leds_flags(bool echoing, bool blip) { f_echo = echoing; f_blip = blip; }+led_pattern_t leds_current(void) { return pat; }++static void set(uint8_t i, uint8_t r, uint8_t g, uint8_t b)+{+    px[3 * i] = g; px[3 * i + 1] = r; px[3 * i + 2] = b;+}++/* triangle 0..255 over 'period' ms, squared for a perceptual fade */+static uint8_t breath(uint32_t t, uint16_t period)+{+    uint32_t ph = t % period, half = period / 2;+    uint32_t tri = ph < half ? ph : period - ph;+    uint16_t lin = (uint16_t)(tri * 255u / half);+    return (uint8_t)((lin * lin) / 255u);+}++static uint8_t level_segments(uint8_t p) { return (uint8_t)((p + 12u) / 25u); }   /* 0..4 */++static void gauge(uint8_t lit, uint8_t fill_frac_255)+{+    for (uint8_t s = 0; s < 4; s++) {+        uint8_t k = s < lit ? 255 : 0;+        if (s == lit && fill_frac_255) k = fill_frac_255;+        if (pct <= 25) set(LED_GAUGE0 + s, (uint8_t)(40u * k / 255u), (uint8_t)(16u * k / 255u), 0);   /* amber */+        else set(LED_GAUGE0 + s, 0, (uint8_t)(40u * k / 255u), (uint8_t)(6u * k / 255u));            /* green */+    }+}++static void render(uint32_t now)+{+    uint32_t t = now - t0;+    for (uint8_t i = 0; i < sizeof px; i++) px[i] = 0;++    switch (pat) {+    case PAT_OFF:+        break;++    case PAT_GAUGE: {                    /* fill one segment per 375 ms, hold */+        uint8_t lit = level_segments(pct);+        if (!lit && pct) lit = 1;+        uint32_t step = t / 375u;+        if (step >= lit) gauge(lit, 0);+        else gauge((uint8_t)step, (uint8_t)((t % 375u) * 255u / 375u));+        uint8_t l = (uint8_t)(t < 3000 ? t * 8u / 3000u : 8);     /* logo wakes up */+        set(LED_LOGO, (uint8_t)(l * 6 / 10), l, l);+        break;+    }++    case PAT_ARMED: {+        uint8_t l = (uint8_t)(3u + (breath(t, 4000) * 21u) / 255u);   /* 3..24 */+        if (f_echo) l = (uint8_t)(l + l / 2);                          /* echoing: a bit brighter */+        if (f_blip) l = ((t / 40u) & 1u) ? (uint8_t)(l * 2u) : 0;     /* re-arm: quick flicker */+        set(LED_LOGO, (uint8_t)(l * 6 / 10), l, l);                   /* soft cyan-white */+        break;+    }++    case PAT_DONE: {                     /* slow warm fade, 2.5 s */+        uint8_t k = t < 2500 ? (uint8_t)(255u - t * 255u / 2500u) : 0;+        k = (uint8_t)((k * k) / 255u);+        set(LED_LOGO, (uint8_t)(40u * k / 255u), (uint8_t)(30u * k / 255u), (uint8_t)(16u * k / 255u));+        break;+    }++    case PAT_LOW:                        /* red blink 4 Hz on logo and the first segment */+        if (((t / 250u) & 1u) == 0) {+            set(LED_LOGO, 40, 0, 0);+            set(LED_GAUGE0, 30, 0, 0);+        }+        break;++    case PAT_CHARGING: {+        uint8_t lit = (uint8_t)(pct / 25u);+        if (lit >= 4) gauge(4, 0);+        else {+            gauge(lit, 0);+            uint8_t k = breath(t, 1500);+            set(LED_GAUGE0 + lit, 0, (uint8_t)(4u + 36u * k / 255u), (uint8_t)(6u * k / 255u));+        }+        break;+    }++    case PAT_FULL:+        if (t < 5000) {+            for (uint8_t s = 0; s < 4; s++) set(LED_GAUGE0 + s, 0, 40, 0);+        } else {+            uint32_t w = (t - 5000) % 10000u;+            if (w < 600) {+                uint8_t k = breath(w, 600);+                for (uint8_t s = 0; s < 4; s++) set(LED_GAUGE0 + s, 0, (uint8_t)(12u * k / 255u), 0);+            }+        }+        break;++    case PAT_SHOW_CHARGE: {+        uint8_t lit = level_segments(pct);+        if (!lit && pct) lit = 1;+        gauge(lit, 0);+        break;+    }+    }++    /* current cap */+    uint16_t sum = 0;+    for (uint8_t i = 0; i < sizeof px; i++) sum += px[i];+    if (sum > LED_SUM_MAX)+        for (uint8_t i = 0; i < sizeof px; i++) px[i] = (uint8_t)(((uint32_t)px[i] * LED_SUM_MAX) / sum);+}++bool leds_frame(uint32_t now)+{+    render(now);++    bool any = false, changed = false;+    for (uint8_t i = 0; i < sizeof px; i++) {+        if (px[i]) any = true;+        if (px[i] != last[i]) changed = true;+    }++    if (!any) {+        if (rail_on && ++dark_frames >= 4) {        /* dark for a while: cut the rail */+            PORTB.OUTCLR = (1 << PIN_LED_DIN);+            PORTB.OUTSET = (1 << PIN_LED_EN_N);+            rail_on = false;+            for (uint8_t i = 0; i < sizeof last; i++) last[i] = 0;+        }+        if (!rail_on) return true;+    } else dark_frames = 0;++    if (!changed && rail_on) return true;++    if (!rail_on) {+        PORTB.OUTCLR = (1 << PIN_LED_EN_N) | (1 << PIN_LED_DIN);+        rail_on = true;+        delay_ms_busy(1);                           /* LED power-on reset */+    }++    if (!echo_quiet()) return false;                /* try again next frame */+    echo_hold(true);+    while (!echo_quiet() && !echo_blanking()) ;     /* a shot that started just now: <= 40 us */+    if (echo_blanking()) { echo_hold(false); return false; }++    cli();+    clock_fast();+    ws_send(px, sizeof px);+    clock_normal();+    sei();+    echo_hold(false);++    for (uint8_t i = 0; i < sizeof px; i++) last[i] = px[i];+    return true;+}
firmware/src/leds.hadded+28
@@ -0,0 +1,28 @@+/*+ * leds.h : WS2812B-2020-V6 chain (5 LEDs) and the light patterns+ */+#ifndef LEDS_H+#define LEDS_H++#include <stdint.h>+#include <stdbool.h>++typedef enum {+    PAT_OFF = 0,+    PAT_GAUGE,          /* session start: gauge fills to the level over 3 s */+    PAT_ARMED,          /* logo breathing soft cyan-white */+    PAT_DONE,           /* 10 min cap: slow warm fade */+    PAT_LOW,            /* low battery: red */+    PAT_CHARGING,       /* filled to the level, next segment pulses */+    PAT_FULL,           /* all four green 5 s, then a dim pulse every 10 s */+    PAT_SHOW_CHARGE     /* static level (2 s after unplugging) */+} led_pattern_t;++void leds_init(void);+void leds_pattern(led_pattern_t p, uint8_t percent, uint32_t now_ms);+void leds_flags(bool echoing, bool blip);   /* modifiers for PAT_ARMED */+bool leds_frame(uint32_t now_ms);           /* render + write if due; false if deferred (echo busy) */+void leds_off(void);                        /* rail off, data low */+led_pattern_t leds_current(void);++#endif
firmware/src/main.cadded+384
@@ -0,0 +1,384 @@+/*+ * main.c : Ghost Hand state machine+ *+ *   SLEEP ----motion----> SETTLE ---still + level 1.5 s---> SESSION+ *     ^  \---VBUS-------> CHARGING <---------VBUS-------------|+ *     |                      | unplug -> show charge 2 s -> SLEEP+ *     +--- picked up / tipped (lights off) ----------------- SESSION+ *     +--- 10 min cap -> DONE pattern ----------------------- SESSION+ *     +--- battery < 3.45 V -> LOW pattern ------------------ SESSION / SETTLE+ *+ * SESSION runs three sub-phases:+ *   GAUGE + LISTEN  charge gauge fills (3 s) while the receiver settles, the+ *                   threshold is calibrated and the faucet is heard with the+ *                   echo disabled (2 s, up to 5 s for slow faucets)+ *   ARMED           echo enabled, learn.c decides blips, emitters rotate,+ *                   logo breathes+ * After DONE or LOW the device must be picked up (0.25 g / 30 deg change)+ * before the next set-down can start a session.+ *+ * Safety: the session cap, the pick-up/tilt stop and the low-battery stop are+ * checked on every 64 Hz tick, independent of learn.c, and the watchdog (1 s)+ * resets the MCU if the loop ever stalls; after reset the emitter gates fall+ * to their 100k pull-downs and the receiver loses power.+ */+#include <avr/io.h>+#include <avr/interrupt.h>+#include "board.h"+#include "power.h"+#include "echo.h"+#include "accel.h"+#include "leds.h"+#include "learn.h"++typedef enum { ST_SLEEP, ST_SETTLE, ST_SESSION, ST_DONE, ST_LOW, ST_CHARGING, ST_UNPLUGGED } state_t;+typedef enum { SUB_RXSETTLE, SUB_LISTEN, SUB_ARMED } sub_t;++static state_t st;+static sub_t sub;+static uint32_t t_state, t_session, t_batt, t_rotate, t_save;+static bool acc_ok, need_pickup, pickup_seen, store_dirty, full_shown;+static uint8_t tick_n, low_count, emit_i;+static vec_mg_t v_now, v_anchor, v_ref, v_done;+static uint32_t t_still;++static learn_store_t store;+static learn_t L;+static learn_listen_t LS;+static uint16_t widths[8];+static uint8_t n_widths;+static uint32_t last_pulse_ticks;+static bool have_pulse;++static const vec_mg_t v_rest = { REST_X_MG, REST_Y_MG, REST_Z_MG };+static const uint8_t emit_seq[3] = { EMIT_BOTH, EMIT_850, EMIT_940 };++#define STORE_ADDR 0+#define STORE_SAVE_MIN_MS 30000u++static void enter(state_t s, uint32_t now) { st = s; t_state = now; }++static uint8_t battery_pct(uint32_t now, bool charging)+{+    uint16_t mv = vbat_mv();+    if (charging && mv > 100) mv -= 100;     /* charger lifts the terminal voltage */+    return vbat_percent(mv);+}++static void save_store(bool force, uint32_t now)+{+    if (!store_dirty) return;+    if (!force && now - t_save < STORE_SAVE_MIN_MS) return;+    store = *learn_store(&L);+    eeprom_write_bytes(STORE_ADDR, &store, sizeof store);+    store_dirty = false;+    t_save = now;+}++/* ------------------------------------------------------------- sleeping */+static void go_sleep(void)+{+    echo_rx_off();+    leds_off();+    if (acc_ok) acc_mode_sleep();+    twi_off();+    time_suspend();+    wdt_off();+    wake_take_accel();+    wake_take_vbus();++    for (;;) {+        cli();+        bool pending = (PORTA.IN & (1 << PIN_ACC_INT1)) || vbus_present();+        if (pending) { sei(); break; }+        sleep_now(SLEEP_POWERDOWN_MODE);            /* sei + sleep inside */+        if (wake_take_accel() || wake_take_vbus() || vbus_present()) break;+    }++    time_resume();+    wdt_on();+    uint32_t now = time_ms();+    if (vbus_present()) {+        enter(ST_CHARGING, now);+        full_shown = false;+        leds_pattern(PAT_CHARGING, battery_pct(now, true), now);+        if (acc_ok) acc_mode_watch(PICKUP_DELTA_MG);+    } else {+        enter(ST_SETTLE, now);+        if (acc_ok) { acc_mode_watch(PICKUP_DELTA_MG); acc_read(&v_anchor); }+        t_still = now;+    }+}++/* ------------------------------------------------------------- sessions */+static void session_start(uint32_t now)+{+    uint16_t mv = vbat_mv();+    if (mv < LOW_BATT_MV) {+        enter(ST_LOW, now);+        need_pickup = true; pickup_seen = false; v_done = v_now;+        if (mv >= NO_LIGHT_BATT_MV) leds_pattern(PAT_LOW, 0, now); else leds_pattern(PAT_OFF, 0, now);+        return;+    }+    enter(ST_SESSION, now);+    t_session = now; t_batt = now; low_count = 0;+    v_ref = v_now;                                  /* pick-up / tilt reference */+    acc_mode_watch(PICKUP_DELTA_MG);+    wake_take_accel();+    leds_pattern(PAT_GAUGE, vbat_percent(mv), now);+    echo_rx_on();+    sub = SUB_RXSETTLE;+}++static void session_stop(void)+{+    echo_rx_off();+    save_store(true, time_ms());+}++static uint32_t ticks_to_session_ms(uint32_t t) { return ((t * 125u) >> 7) - t_session; }++static void drain_listen(void)+{+    echo_pulse_t p;+    while (echo_pop(&p)) {+        if (have_pulse && p.t - last_pulse_ticks < PULSE_GROUP_MS) continue;   /* burst: same interrogation */+        have_pulse = true;+        last_pulse_ticks = p.t;+        learn_listen_pulse(&LS, ticks_to_session_ms(p.t));+        if (n_widths < 8 && p.width_cyc) widths[n_widths++] = p.width_cyc;+    }+}++static uint8_t echo_width_from_listen(void)+{+    if (!n_widths) return ECHO_DEFAULT_US;+    for (uint8_t i = 1; i < n_widths; i++) {       /* median */+        uint16_t x = widths[i]; int8_t j = (int8_t)i - 1;+        while (j >= 0 && widths[j] > x) { widths[j + 1] = widths[j]; j--; }+        widths[j + 1] = x;+    }+    uint16_t us = (uint16_t)(widths[n_widths / 2] / (F_CPU / 1000000UL)) + ECHO_EXTRA_US;+    if (us < ECHO_MIN_US) us = ECHO_MIN_US;+    if (us > ECHO_MAX_US) us = ECHO_MAX_US;+    return (uint8_t)us;+}++static void drain_armed(void)+{+    echo_pulse_t p;+    while (echo_pop(&p)) {+        if (have_pulse && p.t - last_pulse_ticks < PULSE_GROUP_MS) continue;+        have_pulse = true;+        last_pulse_ticks = p.t;+        learn_pulse(&L, ticks_to_session_ms(p.t));+    }+}++static void session_tick(uint32_t now)+{+    uint32_t sms = now - t_session;++    switch (sub) {+    case SUB_RXSETTLE:+        if (sms >= RX_SETTLE_MS) {+            echo_calibrate();+            learn_listen_init(&LS);+            n_widths = 0; have_pulse = false;+            echo_listen_start();+            sub = SUB_LISTEN;+        }+        break;++    case SUB_LISTEN:+        drain_listen();+        if ((sms >= RX_SETTLE_MS + LISTEN_MS && LS.n_pulses >= 3) || sms >= RX_SETTLE_MS + LISTEN_MAX_MS) {+            echo_arm(echo_width_from_listen());+            learn_begin(&L, &store, learn_listen_period(&LS), sms);+            have_pulse = false;+            emit_i = 0; t_rotate = now;+            echo_set(learn_echo_on(&L), emit_seq[0]);+            sub = SUB_ARMED;+        }+        break;++    case SUB_ARMED: {+        drain_armed();+        learn_tick(&L, sms);+        uint8_t ev = learn_take_events(&L);+        if (ev & LEARN_EV_STORE_DIRTY) store_dirty = true;+        save_store(false, now);++        /* "moving hand": rotate 850 / 940 / both every few hundred ms */+        if (now - t_rotate >= EMITTER_ROTATE_MS) {+            t_rotate = now;+            emit_i = (uint8_t)((emit_i + 1) % 3);+        }+        bool on = learn_echo_on(&L);+        echo_set(on, emit_seq[emit_i]);+        leds_flags(on && L.faucet_active, L.phase == LEARN_PH_BLIP);+        break;+    }+    }++    if (sms >= GAUGE_SHOW_MS && leds_current() == PAT_GAUGE) leds_pattern(PAT_ARMED, 0, now);+}++/* returns true if the session must stop now */+static bool session_safety(uint32_t now)+{+    if (now - t_session >= SESSION_CAP_MS) {+        session_stop();+        enter(ST_DONE, now);+        need_pickup = true; pickup_seen = false; v_done = v_now;+        leds_pattern(PAT_DONE, 0, now);+        return true;+    }+    if (vbus_present()) {+        session_stop();+        enter(ST_CHARGING, now);+        full_shown = false;+        leds_pattern(PAT_CHARGING, battery_pct(now, true), now);+        return true;+    }+    bool picked = wake_take_accel();                 /* INT1: > 0.25 g slope */+    if (!picked && (tick_n & 3) == 0) {+        if (!acc_read(&v_now)) picked = true;        /* lost the accelerometer: stop */+        else if (vec_dist2(&v_now, &v_ref) > (uint32_t)PICKUP_DELTA_MG * PICKUP_DELTA_MG ||+                 vec_tilt_over_30(&v_now, &v_ref)) picked = true;+    }+    if (picked) {+        session_stop();+        leds_off();+        go_sleep();                                  /* returns on the next motion or VBUS */+        return true;+    }+    if (now - t_batt >= BATT_CHECK_MS && echo_quiet()) {+        t_batt = now;+        uint16_t mv = vbat_mv();                     /* under load: RX on, light on */+        low_count = mv < LOW_BATT_MV ? (uint8_t)(low_count + 1) : 0;+        if (low_count >= 2) {+            session_stop();+            enter(ST_LOW, now);+            need_pickup = true; pickup_seen = false; v_done = v_now;+            leds_pattern(mv >= NO_LIGHT_BATT_MV ? PAT_LOW : PAT_OFF, 0, now);+            return true;+        }+    }+    return false;+}++/* ------------------------------------------------------------ settling */+static void settle_tick(uint32_t now)+{+    if (vbus_present()) {+        enter(ST_CHARGING, now);+        full_shown = false;+        leds_pattern(PAT_CHARGING, battery_pct(now, true), now);+        return;+    }+    if (!acc_ok) { go_sleep(); return; }+    if ((tick_n & 3) != 0) return;                   /* 16 Hz */+    if (!acc_read(&v_now)) return;++    if (vec_dist2(&v_now, &v_anchor) > (uint32_t)STILL_TOL_MG * STILL_TOL_MG) {+        v_anchor = v_now;                            /* moved: restart the still timer */+        t_still = now;+    }+    if (need_pickup && !pickup_seen &&+        (vec_dist2(&v_now, &v_done) > (uint32_t)PICKUP_DELTA_MG * PICKUP_DELTA_MG || vec_tilt_over_30(&v_now, &v_done)))+        pickup_seen = true;++    bool level = !vec_tilt_over_30(&v_now, &v_rest);+    bool allowed = !need_pickup || pickup_seen;+    if (now - t_still >= SETTLE_STILL_MS && level && allowed) {+        need_pickup = false;+        session_start(now);+        return;+    }+    if (now - t_still >= SETTLE_GIVEUP_MS || now - t_state >= 120000u) go_sleep();+}++/* ------------------------------------------------------------- charging */+static void charging_tick(uint32_t now)+{+    if (!vbus_present()) {+        enter(ST_UNPLUGGED, now);+        leds_pattern(PAT_SHOW_CHARGE, battery_pct(now, false), now);+        return;+    }+    if (charge_complete()) {+        if (!full_shown) { full_shown = true; leds_pattern(PAT_FULL, 100, now); }+    } else {+        if (full_shown) { full_shown = false; leds_pattern(PAT_CHARGING, battery_pct(now, true), now); }+        else if (now - t_state >= 10000u) {       /* refresh the level */+            t_state = now;+            leds_pattern(PAT_CHARGING, battery_pct(now, true), now);+        }+    }+}++/* ----------------------------------------------------------------- main */+int main(void)+{+    clock_init();+    pins_init();+    time_init();+    leds_init();+    echo_init();+    wake_arm();+    sei();+    wdt_on();++    eeprom_read_bytes(STORE_ADDR, &store, sizeof store);+    if (!learn_store_valid(&store)) learn_store_defaults(&store);++    acc_ok = acc_init();+    uint32_t now = time_ms();+    if (acc_ok) acc_mode_watch(PICKUP_DELTA_MG);++    /* power-up (battery connected or reset): show the charge, then sleep */+    if (vbus_present()) {+        enter(ST_CHARGING, now);+        leds_pattern(PAT_CHARGING, battery_pct(now, true), now);+    } else {+        enter(ST_UNPLUGGED, now);+        leds_pattern(acc_ok ? PAT_SHOW_CHARGE : PAT_LOW, battery_pct(now, false), now);+    }++    for (;;) {+        bool ticked = tick_take();+        if (ticked) {+            wdt_kick();+            now = time_ms();+            tick_n++;++            switch (st) {+            case ST_SLEEP:+                go_sleep();+                break;+            case ST_SETTLE:+                settle_tick(now);+                break;+            case ST_SESSION:+                if (!session_safety(now)) session_tick(now);+                break;+            case ST_DONE:+                if (now - t_state >= 3000u) go_sleep();+                break;+            case ST_LOW:+                if (now - t_state >= 2000u) go_sleep();+                break;+            case ST_CHARGING:+                charging_tick(now);+                break;+            case ST_UNPLUGGED:+                if (vbus_present()) { enter(ST_CHARGING, now); leds_pattern(PAT_CHARGING, battery_pct(now, true), now); }+                else if (now - t_state >= 2000u) go_sleep();+                break;+            }+            if ((tick_n & 1) == 0 && st != ST_SLEEP) leds_frame(time_ms());   /* 32 Hz light updates */+        }+        if (!tick_pending()) sleep_now(echo_blanking() ? SLEEP_IDLE_MODE : SLEEP_STANDBY_MODE);+    }+}
firmware/src/power.cadded+275
@@ -0,0 +1,275 @@+/*+ * power.c : clocks, RTC time base, sleep, pins, battery, ADC, watchdog, EEPROM+ *+ * Power states (currents from calcs.json battery_life, typical, 25 C):+ *   SLEEP    power-down, PIT off, wake on PA2 (accel INT1) or PB2 (VBUS) edges.+ *            MCU ~0.1 uA, LIS2DW12 LP1 12.5 Hz wake-up ~1 uA, total with+ *            LDO, charger leakage, the always-on 9.4 Mohm divider (0.42 uA)+ *            and pack protection ~4.7 uA.+ *   AWAKE    (settle, charging display) STANDBY between 64 Hz PIT ticks,+ *            OSC20M off in standby, accel 50 Hz.+ *   SESSION  STANDBY with OSC20M kept on by the TCBs (RUNSTDBY), receiver+ *            powered (1.1 mA), ~2.8 mA total average incl. echo pulses and+ *            the breathing light. IDLE only during the ~0.2 ms blanking+ *            after each echo (TCA0 does not run in standby).+ */+#include <avr/io.h>+#include <avr/interrupt.h>+#include <avr/cpufunc.h>+#include <util/atomic.h>+#include <util/delay.h>+#include "board.h"+#include "power.h"++/* ------------------------------------------------------------------ clock */+void clock_init(void)+{+    _PROTECTED_WRITE(CLKCTRL.MCLKCTRLA, CLKCTRL_CLKSEL_OSC20M_gc);+    _PROTECTED_WRITE(CLKCTRL.MCLKCTRLB, CLKCTRL_PDIV_4X_gc | CLKCTRL_PEN_bm);   /* 5 MHz */+}++/* 10 MHz is inside the 0-10 MHz @ 2.7-5.5 V speed grade (datasheet 1.0+ * "Speed Grades"); 20 MHz would need 4.5 V. The prescaler switch is+ * immediate and glitch free. */+void clock_fast(void)   { _PROTECTED_WRITE(CLKCTRL.MCLKCTRLB, CLKCTRL_PDIV_2X_gc | CLKCTRL_PEN_bm); }+void clock_normal(void) { _PROTECTED_WRITE(CLKCTRL.MCLKCTRLB, CLKCTRL_PDIV_4X_gc | CLKCTRL_PEN_bm); }++void delay_ms_busy(uint16_t ms)+{+    while (ms--) _delay_ms(1);+}++/* -------------------------------------------------------------- time base */+static volatile uint16_t rtc_hi;+static volatile uint8_t pit_ticks;++void time_init(void)+{+    while (RTC.STATUS) ;+    RTC.CLKSEL = RTC_CLKSEL_INT32K_gc;         /* OSCULP32K */+    RTC.PER = 0xFFFF;+    RTC.INTCTRL = RTC_OVF_bm;+    RTC.CTRLA = RTC_PRESCALER_DIV32_gc | RTC_RUNSTDBY_bm | RTC_RTCEN_bm;   /* 1024 Hz */+    time_resume();+}++void time_resume(void)+{+    while (RTC.PITSTATUS) ;+    RTC.PITINTCTRL = RTC_PI_bm;+    RTC.PITCTRLA = RTC_PERIOD_CYC512_gc | RTC_PITEN_bm;   /* 32768 / 512 = 64 Hz */+}++void time_suspend(void)+{+    while (RTC.PITSTATUS) ;+    RTC.PITCTRLA = 0;                          /* the PIT would wake power-down */+}++uint32_t time_ticks(void)+{+    uint16_t hi, lo;+    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {+        hi = rtc_hi;+        lo = RTC.CNT;+        if ((RTC.INTFLAGS & RTC_OVF_bm) && lo < 0x8000u) hi++;   /* overflow not yet serviced */+    }+    return ((uint32_t)hi << 16) | lo;+}++uint32_t time_ms(void)+{+    return (time_ticks() * 125u) >> 7;         /* * 1000 / 1024, fine for 9 h */+}++bool tick_take(void)+{+    bool t = false;+    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {+        if (pit_ticks) { pit_ticks--; t = true; }+    }+    return t;+}++bool tick_pending(void) { return pit_ticks != 0; }++ISR(RTC_CNT_vect)+{+    RTC.INTFLAGS = RTC_OVF_bm;+    rtc_hi++;+}++ISR(RTC_PIT_vect)+{+    RTC.PITINTFLAGS = RTC_PI_bm;+    if (pit_ticks < 8) pit_ticks++;+}++/* ------------------------------------------------------------------ sleep */+void sleep_now(sleep_mode_t m)+{+    uint8_t s = m == SLEEP_POWERDOWN_MODE ? SLPCTRL_SMODE_PDOWN_gc+              : m == SLEEP_STANDBY_MODE ? SLPCTRL_SMODE_STDBY_gc : SLPCTRL_SMODE_IDLE_gc;+    SLPCTRL.CTRLA = s | SLPCTRL_SEN_bm;+    sei();                                     /* sei; sleep: no interrupt slips in between */+    __asm__ __volatile__("sleep");+    SLPCTRL.CTRLA = 0;+}++/* ------------------------------------------------------------------- pins */+static volatile bool f_accel, f_vbus;++void pins_init(void)+{+    /* PORTA: PA1 CHG_STAT in, PA2 ACC_INT1 in, PA3/PA5 gates (inputs + 100k+     * pull-downs until armed), PA4 RX_PWR out low, PA6/PA7 analog */+    PORTA.OUTCLR = (1 << PIN_RX_PWR) | (1 << PIN_IR850_G) | (1 << PIN_IR940_G);+    PORTA.DIR = (1 << PIN_RX_PWR);+    PORTA.PIN1CTRL = 0;+    PORTA.PIN6CTRL = PORT_ISC_INPUT_DISABLE_gc;   /* VBAT_SENSE is analog (<= 2.1 V) */+    PORTA.PIN7CTRL = PORT_ISC_INPUT_DISABLE_gc;   /* RX_SIG analog */++    /* PORTB: PB4 high (LED rail off), PB5 low (no back-powering), PB3 unused+     * (unconnected since the divider became always-on) with its pull-up */+    PORTB.OUT = (1 << PIN_LED_EN_N);+    PORTB.DIR = (1 << PIN_LED_EN_N) | (1 << PIN_LED_DIN);+    PORTB.PIN3CTRL = PORT_PULLUPEN_bm;++    /* PORTC: PC2 EVOUT2 out low, PC1/PC3 unused with pull-ups, PC0 ACC_INT2 in */+    PORTC.OUT = 0;+    PORTC.DIR = (1 << PIN_ECHO_EVT);+    PORTC.PIN1CTRL = PORT_PULLUPEN_bm;+    PORTC.PIN3CTRL = PORT_PULLUPEN_bm;+    PORTC.PIN0CTRL = 0;+}++/* PA2 and PB2 are "fully asynchronous" pins (Px2/Px6): edge sensing works in+ * power-down without a clock. The other pins could only wake on both edges or+ * level, so these two nets are on the right pins. */+void wake_arm(void)+{+    PORTA.PIN2CTRL = PORT_ISC_RISING_gc;+    PORTB.PIN2CTRL = PORT_ISC_BOTHEDGES_gc;+}++bool wake_take_accel(void)+{+    bool f;+    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { f = f_accel; f_accel = false; }+    return f;+}++bool wake_take_vbus(void)+{+    bool f;+    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { f = f_vbus; f_vbus = false; }+    return f;+}++ISR(PORTA_PORT_vect)+{+    uint8_t f = PORTA.INTFLAGS;+    PORTA.INTFLAGS = f;+    if (f & (1 << PIN_ACC_INT1)) f_accel = true;+}++ISR(PORTB_PORT_vect)+{+    uint8_t f = PORTB.INTFLAGS;+    PORTB.INTFLAGS = f;+    if (f & (1 << PIN_VBUS_SENSE)) f_vbus = true;+}++/* -------------------------------------------------------- battery, charger */+bool vbus_present(void)    { return PORTB.IN & (1 << PIN_VBUS_SENSE); }+bool charge_complete(void) { return vbus_present() && (PORTA.IN & (1 << PIN_CHG_STAT)); }++/* VBAT_SENSE is VBAT / 2 from an always-on 4.7M / 4.7M divider (0.45 uA),+ * never above 2.1 V, with C6 = 100 nF on the tap. The source impedance is+ * 2.35 Mohm (tau 235 ms), far too high to charge the ADC sample capacitor+ * through, so C6 is what the ADC samples: one sample takes a few pF from+ * 100 nF (< 0.1 mV) and C6 recovers between readings. The reading uses the+ * longest sample time (SAMPCTRL = 31), 16 accumulated conversions, and the+ * average of 4 such reads. Pin leakage flows through 2.35 Mohm too: 1 nA+ * typical is 2.4 mV at the pin (4.7 mV at VBAT), small next to the 3.45 V+ * stop threshold, but worth a one-point check against a meter per board. */+uint16_t vbat_mv(void)+{+    uint32_t acc = 0;+    for (uint8_t i = 0; i < 4; i++) acc += adc_read_mv(ADC_MUXPOS_AIN6_gc, true, 4, 31);+    return (uint16_t)((acc * 2u + 2u) / 4u);+}++/* resting LiPo curve (approximate, 1S), mV -> % */+static const uint16_t curve_mv[] = { 3450, 3620, 3700, 3750, 3790, 3820, 3870, 3920, 4000, 4100, 4200 };+static const uint8_t  curve_pc[] = {    0,    7,   16,   26,   37,   47,   58,   68,   79,   90,  100 };++uint8_t vbat_percent(uint16_t mv)+{+    if (mv <= curve_mv[0]) return 0;+    for (uint8_t i = 1; i < sizeof curve_mv / sizeof curve_mv[0]; i++) {+        if (mv <= curve_mv[i]) {+            uint16_t d = curve_mv[i] - curve_mv[i - 1];+            return (uint8_t)(curve_pc[i - 1] ++                             ((uint32_t)(mv - curve_mv[i - 1]) * (curve_pc[i] - curve_pc[i - 1]) + d / 2) / d);+        }+    }+    return 100;+}++/* -------------------------------------------------------------------- ADC */+uint16_t adc_read_mv(uint8_t muxpos, bool ref_2v5, uint8_t nsamp_log2, uint8_t sampctrl)+{+    VREF.CTRLA = (uint8_t)((VREF.CTRLA & ~VREF_ADC0REFSEL_gm) |+                           (ref_2v5 ? VREF_ADC0REFSEL_2V5_gc : VREF_ADC0REFSEL_1V1_gc));+    ADC0.CTRLA = 0;+    ADC0.CTRLB = nsamp_log2;                                 /* SAMPNUM: 2^n accumulated */+    ADC0.CTRLC = ADC_SAMPCAP_bm | ADC_REFSEL_INTREF_gc | ADC_PRESC_DIV8_gc;   /* 625 kHz */+    ADC0.CTRLD = ADC_INITDLY_DLY32_gc;                       /* reference settle */+    ADC0.SAMPCTRL = sampctrl;                                /* extra sample cycles (0..31) */+    ADC0.MUXPOS = muxpos;+    ADC0.INTFLAGS = ADC_RESRDY_bm;+    ADC0.CTRLA = ADC_ENABLE_bm;+    ADC0.COMMAND = ADC_STCONV_bm;+    while (!(ADC0.INTFLAGS & ADC_RESRDY_bm)) ;+    uint16_t r = ADC0.RES >> nsamp_log2;+    ADC0.CTRLA = 0;+    return (uint16_t)(((uint32_t)r * (ref_2v5 ? 2500u : 1100u)) >> 10);+}++/* --------------------------------------------------------------- watchdog */+void wdt_on(void)+{+    while (WDT.STATUS & WDT_SYNCBUSY_bm) ;+    _PROTECTED_WRITE(WDT.CTRLA, WDT_PERIOD_1KCLK_gc);       /* ~1 s */+}++void wdt_off(void)+{+    while (WDT.STATUS & WDT_SYNCBUSY_bm) ;+    _PROTECTED_WRITE(WDT.CTRLA, 0);+}++void wdt_kick(void) { __builtin_avr_wdr(); }++/* ----------------------------------------------------------------- EEPROM */+void eeprom_read_bytes(uint8_t addr, void *dst, uint8_t n)+{+    const volatile uint8_t *e = (const volatile uint8_t *)(MAPPED_EEPROM_START + addr);+    uint8_t *d = (uint8_t *)dst;+    while (NVMCTRL.STATUS & NVMCTRL_EEBUSY_bm) ;+    while (n--) *d++ = *e++;+}++/* Page buffer write through the memory map, then erase+write the page.+ * The caller keeps the block inside one 32-byte page. */+void eeprom_write_bytes(uint8_t addr, const void *src, uint8_t n)+{+    volatile uint8_t *e = (volatile uint8_t *)(MAPPED_EEPROM_START + addr);+    const uint8_t *s = (const uint8_t *)src;+    while (NVMCTRL.STATUS & NVMCTRL_EEBUSY_bm) ;+    _PROTECTED_WRITE_SPM(NVMCTRL.CTRLA, NVMCTRL_CMD_PAGEBUFCLR_gc);+    while (n--) *e++ = *s++;+    _PROTECTED_WRITE_SPM(NVMCTRL.CTRLA, NVMCTRL_CMD_PAGEERASEWRITE_gc);+    while (NVMCTRL.STATUS & NVMCTRL_EEBUSY_bm) ;+}
firmware/src/power.hadded+55
@@ -0,0 +1,55 @@+/*+ * power.h : clocks, time base, sleep modes, battery / USB sensing, EEPROM, watchdog+ */+#ifndef POWER_H+#define POWER_H++#include <stdint.h>+#include <stdbool.h>++/* ---- clock ---- */+void     clock_init(void);            /* OSC20M / 4 = 5 MHz */+void     clock_fast(void);            /* OSC20M / 2 = 10 MHz (LED writes only) */+void     clock_normal(void);          /* back to 5 MHz */++/* ---- time base: RTC on OSCULP32K / 32 = 1024 Hz, PIT 64 Hz tick ---- */+#define TICKS_PER_S 1024u+void     time_init(void);+void     time_suspend(void);          /* before power-down */+void     time_resume(void);+uint32_t time_ticks(void);            /* 1/1024 s, safe in and out of ISRs */+uint32_t time_ms(void);               /* ticks converted to ms */+bool     tick_take(void);             /* true once per PIT tick (64 Hz) */+bool     tick_pending(void);          /* a tick is waiting (do not sleep) */++/* ---- sleep ---- */+typedef enum { SLEEP_IDLE_MODE, SLEEP_STANDBY_MODE, SLEEP_POWERDOWN_MODE } sleep_mode_t;+void     sleep_now(sleep_mode_t m);++/* ---- pins and wake sources ---- */+void     pins_init(void);             /* every pin in its safe / low-power state */+void     wake_arm(void);              /* PA2 rising + PB2 rising wake power-down */+bool     wake_take_accel(void);       /* ACC_INT1 edge seen since the last call */+bool     wake_take_vbus(void);        /* VBUS_SENSE edge seen since the last call */++/* ---- battery and charger ---- */+bool     vbus_present(void);+bool     charge_complete(void);       /* CHG_STAT high while VBUS present */+uint16_t vbat_mv(void);               /* always-on divider, long sampling, averaged */+uint8_t  vbat_percent(uint16_t mv);   /* LiPo curve, 4.20 V = 100, 3.45 V = 0 */++/* ---- ADC0 (shared by the battery and the receiver noise floor) ---- */+uint16_t adc_read_mv(uint8_t muxpos, bool ref_2v5, uint8_t nsamp_log2, uint8_t sampctrl);++/* ---- watchdog ---- */+void     wdt_on(void);                /* 1 s */+void     wdt_off(void);+void     wdt_kick(void);++/* ---- EEPROM ---- */+void     eeprom_read_bytes(uint8_t addr, void *dst, uint8_t n);+void     eeprom_write_bytes(uint8_t addr, const void *src, uint8_t n);  /* one page (32 B) max */++void     delay_ms_busy(uint16_t ms);++#endif
firmware/test/Makefileadded+14
@@ -0,0 +1,14 @@+# host-side tests of the learner (plain gcc, no AVR toolchain needed)+CC ?= gcc+CFLAGS := -O2 -std=c11 -Wall -Wextra -Werror -I../src++all: run++test_learn: test_learn.c ../src/learn.c ../src/learn.h+	$(CC) $(CFLAGS) test_learn.c ../src/learn.c -o $@++run: test_learn+	./test_learn++clean:+	rm -f test_learn
firmware/test/test_learn.cadded+260
@@ -0,0 +1,260 @@+/*+ * 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;+}
page.json+14−5
@@ -3,7 +3,7 @@   "slug": "ghost-hand",   "type": "component",   "title": "Ghost Hand",-  "brief": "A pocket IR transponder that keeps an automatic sink faucet running while you use it: learns the faucet's timeout, re-arms with the shortest blip, wakes when set down.",+  "brief": "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.",   "version": "0.1.0",   "kind": "project",   "hero": {@@ -13,11 +13,20 @@     "chrome": false   },   "author": {-    "name": "John Lauer",-    "email": "[email protected]"+    "name": "John Lauer"   },-  "tags": [],-  "license": "proprietary",+  "tags": [+    "ir",+    "faucet",+    "attiny1616",+    "lipo",+    "usb-c",+    "molecule",+    "aiflow",+    "3d-print",+    "open-hardware"+  ],+  "license": "MIT",   "visibility": {     "public": true   },