Skip to content

Commit 86f136e

Browse files
WaylandYangclaude
andcommitted
examples: add 5 starter recipes covering the full protocol surface
Before this commit, the only canonical reference for "how do I use DCP" was the lamp example. A new user wanting to build a thermometer, a relay switch, or a motor control had to reverse-engineer the pattern. This commit adds five compile-clean device skeletons — each chosen to teach a distinct slice of the protocol — plus an index page in docs/RECIPES.md. Coverage: relay_switch bool param + idempotent + capability scope + non-idempotent pulse intent sensor_dht22 read intent with units + unsolicited event push on threshold + device-tracked mutable config stepper_motor int + duration params + dry_run as motion preview (LLM can ask "where would this move end?" without spinning the shaft) + non-idempotent move encoder_input the "event-only device" pattern — zero intents, pure event source. Bindings array is literally empty; firmware only ever calls send_event() door_lock capability + dry_run as REAL safety story — capability is `lock.admin` (not lock.write) so the name signals seriousness, and admin requires an explicitly-minted scoped token at runtime. String-typed return values + state-change events on any cause (including manual override). Each recipe is a pair: examples/<name>_manifest.yaml + a corresponding firmware/esp32/examples/<name>/<name>.ino sketch. All five compile clean on both the ESP32 baseline (~289 KB / 22.5 KB) and ESP8266 NodeMCU (~239 KB IROM). Per-recipe variation against the protocol baseline is under 1 KB — adding a recipe doesn't grow the DCP layer. Hardware drivers in sensor_dht22 and door_lock are stubbed (mock read functions, gpio-only actuator) so the skeletons compile without any third-party library install. Each .ino has a clearly marked "replace this with your driver" comment. docs/RECIPES.md is the new index — table of recipes, per-recipe explanation of what concept it teaches, and a "build your own" pointer to ADDING_FEATURES.md. README TOC updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 62627cb commit 86f136e

12 files changed

Lines changed: 1003 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- [Architecture](#architecture)
2424
- [Quickstart](#quickstart)
2525
- [Add a feature in 5 steps](docs/ADDING_FEATURES.md)
26+
- [Recipes — five ready-to-flash device skeletons](docs/RECIPES.md)
2627
- [Wire format](#wire-format) · full [SPEC.md](SPEC.md)
2728
- [Manifest](#manifest)
2829
- [Roadmap](#roadmap)

docs/RECIPES.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# DCP recipes
2+
3+
Ready-to-flash skeletons for the five protocol shapes you'll most
4+
commonly want. Each one teaches a different slice of DCP and runs on
5+
an ESP32-class MCU. Hardware is cheap enough that you can buy the
6+
whole bench for ~¥40.
7+
8+
| Recipe | What you'll learn | Approx. hardware cost |
9+
|---|---|---|
10+
| [`relay_switch`](#relay_switch) | bool param · idempotent · capability · non-idempotent pulse | ¥5 (5V relay module) |
11+
| [`sensor_dht22`](#sensor_dht22) | typed read intent with units · unsolicited event push · device-tracked config | ¥10 (DHT22) |
12+
| [`stepper_motor`](#stepper_motor) | int + duration params · `dry_run` as preview · non-idempotent move | ¥10 (28BYJ-48 + ULN2003) |
13+
| [`encoder_input`](#encoder_input) | **event-only device** — zero intents | ¥3 (KY-040) |
14+
| [`door_lock`](#door_lock) | capability + `dry_run` as **real safety story** · string return types · state-change events | ¥10 (SG90 servo) |
15+
16+
Each recipe is a pair: a manifest (`examples/<name>_manifest.yaml`)
17+
and a firmware sketch (`firmware/esp32/examples/<name>/<name>.ino`).
18+
All five cross-compile clean on the full
19+
[ESP family build matrix](../README.md#cross-compile-clean-across-the-esp-family-xtensa--risc-v--esp8266).
20+
21+
## Quick start (any recipe)
22+
23+
```bash
24+
# 1. Wire your hardware per the comments at the top of the .ino
25+
# 2. Flash:
26+
arduino-cli compile --upload -p COM5 --fqbn esp32:esp32:esp32 \
27+
--library firmware/esp32 firmware/esp32/examples/relay
28+
# 3. Run the bridge:
29+
dcp serve examples/relay_manifest.yaml --serial COM5
30+
# 4. Point your MCP client (Claude Desktop / Ollama / etc.) at dcp.
31+
```
32+
33+
---
34+
35+
## relay_switch
36+
37+
A single-channel 5V relay (door buzzer, appliance switch, fan).
38+
39+
- 3 intents: `set_relay(state)`, `read_relay()`, `pulse(duration)`
40+
- The `set_relay` intent is **idempotent** — setting "on" twice still
41+
leaves it on. `pulse` is **non-idempotent** (two calls = two pulses)
42+
and the manifest declares that explicitly.
43+
- Both `set_relay` and `pulse` are gated on `relay.write`; `read_relay`
44+
only needs `relay.read`. A read-only session token cannot switch the
45+
appliance.
46+
47+
**Files**: [`examples/relay_manifest.yaml`](../examples/relay_manifest.yaml) ·
48+
[`firmware/esp32/examples/relay/relay.ino`](../firmware/esp32/examples/relay/relay.ino)
49+
50+
---
51+
52+
## sensor_dht22
53+
54+
A DHT22 temperature + humidity sensor that PUSHES events when
55+
temperature crosses a configurable threshold — no polling needed
56+
from the LLM.
57+
58+
- 2 read intents (`read_temperature`, `read_humidity`) with explicit
59+
`unit: celsius` / `unit: percent` declared in the manifest, so the
60+
LLM tool schema surfaces them and stops asking "Fahrenheit?"
61+
- 1 write intent (`set_alert_threshold`) that mutates device state
62+
used by the event loop
63+
- 1 event (`threshold_exceeded`) emitted on rising edge of "above
64+
threshold," with payload `{temperature, threshold}`
65+
66+
The DHT22 driver call is stubbed (`read_dht_*()` returns mock data) so
67+
the example runs headlessly during compile-test. Drop in your
68+
favourite library (e.g. Adafruit DHT) at those two function bodies.
69+
70+
**Files**: [`examples/sensor_dht22_manifest.yaml`](../examples/sensor_dht22_manifest.yaml) ·
71+
[`firmware/esp32/examples/sensor_dht22/sensor_dht22.ino`](../firmware/esp32/examples/sensor_dht22/sensor_dht22.ino)
72+
73+
---
74+
75+
## stepper_motor
76+
77+
A 28BYJ-48 stepper driven by a ULN2003 board — curtain, valve, dial,
78+
small actuator, etc.
79+
80+
- 3 intents: `step(direction, count, speed_rpm)`, `read_position()`,
81+
`home()`
82+
- This is where **dry_run gets genuinely useful**: an LLM can ask
83+
"what step position would this move end at?" and get
84+
`{would_move_to: 2700, from: 1234}` without spinning the shaft.
85+
Saves wear, time, and lets the LLM reason about reachability.
86+
- `step` and `home` are **non-idempotent** (each call advances state).
87+
The manifest declares that explicitly so the LLM knows not to retry.
88+
- Half-step coil sequence implemented inline; coils released after
89+
every move to avoid overheating.
90+
91+
**Files**: [`examples/stepper_manifest.yaml`](../examples/stepper_manifest.yaml) ·
92+
[`firmware/esp32/examples/stepper/stepper.ino`](../firmware/esp32/examples/stepper/stepper.ino)
93+
94+
---
95+
96+
## encoder_input
97+
98+
A KY-040 rotary encoder with integrated push button.
99+
100+
This is the canonical **"the device has no intents"** pattern. The
101+
LLM does not command the encoder; it gets notified when the user
102+
turns or clicks. The `intents:` list in the manifest is literally
103+
empty, the bindings array in the firmware is literally empty, and the
104+
firmware only ever calls `send_event()`.
105+
106+
Useful for: volume knobs, menu wheels, RPM counters, manual override
107+
inputs — anything where the LLM should react to user input but never
108+
drive that input itself.
109+
110+
- 3 events: `encoder_turned(delta, position)`, `button_pressed`,
111+
`button_long_press(held_ms)`
112+
- Polled in `loop()` with 5 ms debounce on CLK and 20 ms on the
113+
button; long-press fires after 1 s of continuous press.
114+
115+
**Files**: [`examples/encoder_manifest.yaml`](../examples/encoder_manifest.yaml) ·
116+
[`firmware/esp32/examples/encoder/encoder.ino`](../firmware/esp32/examples/encoder/encoder.ino)
117+
118+
---
119+
120+
## door_lock
121+
122+
The capability + dry_run safety story made concrete: a servo (or
123+
solenoid) actuated door lock.
124+
125+
- The capability name is **`lock.admin`**, not `lock.write` — the
126+
manifest itself signals "this is dangerous." A reviewer reading the
127+
manifest immediately sees that this intent is in a separate trust
128+
tier from "read the temperature."
129+
- `dry_run` is the safety primitive in action: an LLM can be
130+
ALLOWED to call `unlock` with `__dry_run__=true` (cheap,
131+
reversible — returns "would transition locked → unlocked") but the
132+
REAL unlock requires the `lock.admin` capability, which the
133+
operator typically mints out of band with a short TTL:
134+
135+
```bash
136+
dcp token mint --caps lock.read,lock.admin --ttl 300
137+
```
138+
139+
- The Bridge in the recommended `dcp serve --grant lock.read`
140+
configuration grants **only read** by default. Admin must be
141+
presented as a signed token per call.
142+
- A `state_changed(from, to)` event fires on any transition,
143+
regardless of cause — so manual key turns are observable too.
144+
145+
**Files**: [`examples/door_lock_manifest.yaml`](../examples/door_lock_manifest.yaml) ·
146+
[`firmware/esp32/examples/door_lock/door_lock.ino`](../firmware/esp32/examples/door_lock/door_lock.ino)
147+
148+
---
149+
150+
## Adding your own recipe
151+
152+
The protocol is the same regardless of device. Use one of the above
153+
as your starting template, change the manifest to declare your
154+
intents and events, and replace the firmware handlers with your
155+
hardware-specific code. The full add-a-feature walkthrough is in
156+
[`ADDING_FEATURES.md`](ADDING_FEATURES.md).
157+
158+
If you build a recipe for a piece of hardware that isn't covered
159+
here (BME280, NeoPixel strip, ultrasonic sensor, GPS module,
160+
PCA9685, ADS1115…), please open a PR — the cookbook grows by
161+
contribution.
162+
163+
## Footprint stays flat
164+
165+
Adding a recipe doesn't grow the DCP layer. All five recipes above
166+
land at the same ~290 KB / ~22.5 KB on ESP32-WROOM-32 — the
167+
variation is in the example sketch's own logic, not in any per-recipe
168+
protocol cost. The intent table is `switch(intent_id) → handler`, and
169+
each intent adds one row and one function. There is no plugin loader,
170+
no runtime registration, no per-handler dispatcher overhead.

examples/door_lock_manifest.yaml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
dcp: 0.3
2+
device:
3+
id: door-lock-frontdoor-01
4+
model: servo_actuated_lock
5+
vendor: example.dev
6+
7+
# Recipe: a smart door lock — the "capability + dry_run as real safety" demo.
8+
# Teaches:
9+
# - capability scoping that GENUINELY MATTERS (the LLM should not be
10+
# able to unlock your door just because it can also read the
11+
# temperature). We use `lock.admin`, NOT `lock.write`, so the
12+
# capability name itself signals "this is dangerous."
13+
# - dry_run as a safety mechanism for irreversible actions: the LLM
14+
# can preview "if I issue this unlock, the device state would go
15+
# from locked -> unlocked" without actually unlatching.
16+
# - non-idempotent admin actions explicitly declared
17+
# - state-change events so anyone watching can see who/when
18+
#
19+
# Hardware (when wiring a real lock):
20+
# - Hobby servo (SG90) on GPIO 13, signal only (servo VCC -> external 5V!)
21+
# - For solenoid locks: replace the servo helper with relay control
22+
#
23+
# Pair with:
24+
# dcp serve examples/door_lock_manifest.yaml --serial COM3 \
25+
# --grant lock.read # default: only read access
26+
#
27+
# Then mint a scoped admin token explicitly when you (the LLM operator)
28+
# want to allow unlock for a specific session:
29+
# dcp token mint --caps lock.read,lock.admin --ttl 300
30+
31+
intents:
32+
- name: unlock
33+
capability: lock.admin # NOT lock.write — name signals danger
34+
idempotent: false # one physical unlock per call
35+
dry_run: true # LLM can preview without unlatching
36+
37+
- name: lock
38+
capability: lock.admin
39+
idempotent: true # locking already-locked door is a no-op
40+
dry_run: true
41+
42+
- name: read_state
43+
# "locked" or "unlocked"
44+
returns: { type: string }
45+
capability: lock.read
46+
47+
events:
48+
- name: state_changed
49+
# Emitted whenever the lock physically changes state, regardless of
50+
# whether the change came from an intent call or a manual override.
51+
payload:
52+
from: { type: string } # "locked" / "unlocked"
53+
to: { type: string }
54+
capability: lock.read

examples/encoder_manifest.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
dcp: 0.3
2+
device:
3+
id: encoder-volume-01
4+
model: ky040_rotary_encoder
5+
vendor: example.dev
6+
7+
# Recipe: a KY-040 rotary encoder with integrated push-button.
8+
# Teaches:
9+
# - the "event-only device" pattern — the device has NO intents
10+
# callable from the LLM side. It is a pure event source.
11+
# The LLM gets notifications about turns and clicks; it does not
12+
# command the encoder to do anything.
13+
# - structured event payloads with multiple fields + units
14+
#
15+
# Hardware: KY-040 module — CLK + DT to two GPIOs with pullups,
16+
# SW (button) to a third GPIO with pullup.
17+
# Pair with:
18+
# dcp serve examples/encoder_manifest.yaml --serial COM3
19+
20+
# Empty intents list — this device only PUSHES, never RECEIVES.
21+
intents: []
22+
23+
events:
24+
- name: encoder_turned
25+
payload:
26+
# +1 = clockwise one detent, -1 = counterclockwise.
27+
delta: { type: int, range: [-10, 10] }
28+
# Absolute position since power-on. Wraps at int32 limits.
29+
position: { type: int }
30+
capability: input.read
31+
32+
- name: button_pressed
33+
# Fires once on press down (not on hold or release).
34+
capability: input.read
35+
36+
- name: button_long_press
37+
# Fires once when held > 1 second.
38+
payload:
39+
held_ms: { type: duration, unit: ms }
40+
capability: input.read

examples/relay_manifest.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
dcp: 0.3
2+
device:
3+
id: relay-garage-01
4+
model: generic_5v_relay
5+
vendor: example.dev
6+
7+
# Recipe: a single-channel 5V relay (door buzzer / appliance switch / fan).
8+
# Teaches:
9+
# - boolean parameter
10+
# - idempotent intents (setting "on" twice still leaves it on)
11+
# - capability scopes that genuinely matter
12+
# (relay.read can monitor; only relay.write can switch)
13+
# - a non-idempotent pulse intent that MUST declare itself
14+
15+
intents:
16+
- name: set_relay
17+
params:
18+
state: { type: bool }
19+
capability: relay.write
20+
idempotent: true
21+
dry_run: true
22+
23+
- name: read_relay
24+
returns: { type: bool }
25+
capability: relay.read
26+
27+
- name: pulse
28+
# Energise the relay for `duration` ms, then de-energise.
29+
# Useful for door buzzers, magnetic locks, momentary signals.
30+
params:
31+
duration: { type: duration, unit: ms, range: [50, 5000], default: 200 }
32+
capability: relay.write
33+
idempotent: false # calling twice = two pulses, not one
34+
dry_run: true
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
dcp: 0.3
2+
device:
3+
id: sensor-living-room-01
4+
model: dht22_temp_humidity
5+
vendor: example.dev
6+
7+
# Recipe: a DHT22 temperature + humidity sensor.
8+
# Teaches:
9+
# - read intents that return typed scalar values with explicit units
10+
# (`celsius`, `percent`) — the LLM sees these in the tool schema
11+
# and stops asking "Fahrenheit?"
12+
# - an unsolicited event stream — the device pushes notifications
13+
# when temperature crosses a configured threshold, no polling
14+
# - a "configure" intent that mutates device state used by events
15+
#
16+
# Hardware: DHT22 (AM2302), data pin to DHT_PIN below, 4.7 KOhm pullup
17+
# to VCC. Pair with:
18+
# dcp serve examples/sensor_dht22_manifest.yaml --serial COM3
19+
20+
intents:
21+
- name: read_temperature
22+
returns: { type: float, unit: celsius }
23+
capability: env.read
24+
25+
- name: read_humidity
26+
returns: { type: float, unit: percent, range: [0, 100] }
27+
capability: env.read
28+
29+
- name: set_alert_threshold
30+
# Reconfigure the temperature above which the device emits a
31+
# threshold_exceeded event. Persists until next reboot.
32+
params:
33+
temperature: { type: float, unit: celsius, range: [-40, 80], default: 30.0 }
34+
capability: env.write
35+
idempotent: true
36+
dry_run: true
37+
38+
events:
39+
- name: threshold_exceeded
40+
payload:
41+
temperature: { type: float, unit: celsius }
42+
threshold: { type: float, unit: celsius }
43+
capability: env.read

examples/stepper_manifest.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
dcp: 0.3
2+
device:
3+
id: stepper-curtain-01
4+
model: 28byj48_uln2003
5+
vendor: example.dev
6+
7+
# Recipe: a 28BYJ-48 stepper motor driven by a ULN2003 board.
8+
# Teaches:
9+
# - non-idempotent intents (every step() call advances state)
10+
# - dry_run as ACTUALLY VALUABLE — the LLM can preview the new motor
11+
# position before committing the move ("would_move_to: step 2700")
12+
# - integer + duration param mix
13+
# - device-tracked state (position) exposed via a read intent
14+
#
15+
# Hardware: 28BYJ-48 stepper, ULN2003 driver board, IN1..IN4 -> 4 GPIOs.
16+
# 4096 native steps per revolution (with internal 64:1 gearbox).
17+
# Pair with:
18+
# dcp serve examples/stepper_manifest.yaml --serial COM3
19+
20+
intents:
21+
- name: step
22+
# Move the motor by `count` steps in the given `direction`.
23+
# +1 = forward, -1 = reverse. The Bridge enforces both ranges.
24+
params:
25+
direction: { type: int, range: [-1, 1] }
26+
count: { type: int, range: [1, 4096], default: 512 }
27+
speed_rpm: { type: int, range: [1, 15], default: 8 }
28+
capability: motor.write
29+
idempotent: false # cumulative — calling twice doubles the move
30+
dry_run: true
31+
32+
- name: read_position
33+
# Current absolute step count (signed, can be negative after reverse).
34+
returns: { type: int }
35+
capability: motor.read
36+
37+
- name: home
38+
# Move back to position 0. Non-idempotent because if you call it
39+
# twice quickly the motor might already be moving from the first call.
40+
capability: motor.write
41+
idempotent: false
42+
dry_run: true

0 commit comments

Comments
 (0)