project
Ghost Hand
Public Unreviewedby John Lauer
A small battery gadget that keeps an automatic sink faucet running while you use the sink: it learns the faucet's timeout, re-arms with the shortest blip, and stops after 10 minutes or when picked up.
← Commit history
Studio: project overview, how it works, lights, charging, battery life, firmware source, dashboard screenshots; license MIT
27 files changed
+3860−6
README.md+156−1docs/studio/architecture.pngdocs/studio/battery.pngdocs/studio/charging.pngdocs/studio/ir.pngdocs/studio/learning.pngdocs/studio/lights.pngdocs/studio/model3d.pngfirmware/.gitignore+3firmware/Makefile+87firmware/README.md+295firmware/build/ghost-hand.hex+823firmware/src/accel.c+182firmware/src/accel.h+23firmware/src/board.h+116firmware/src/echo.c+294firmware/src/echo.h+35firmware/src/learn.c+429firmware/src/learn.h+141firmware/src/leds.c+246firmware/src/leds.h+28firmware/src/main.c+384firmware/src/power.c+275firmware/src/power.h+55firmware/test/Makefile+14firmware/test/test_learn.c+260page.json+14−5README.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.++++### 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 | |++++### 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.++++## 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 |++++## 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.++++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.++++## 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.++++## 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 },