Skip to main content

LED Control

Every Intern device has an 8-LED WS2812 ring on the front. The default colors track the device lifecycle (cyan when thinking, blue while idle, red on errors, white during boot, …), but you can override them per-state through a small HTTP API exposed by intern-server.

This page covers everything you need to drive the ring from your laptop, phone, or any other device on the same Wi-Fi.

How it works

The ring is controlled exclusively by intern-server, which listens on port 5000 of the device's Wi-Fi IP. You cannot drive the WS2812 chain directly from /sys/class/leds/, pinctrl, gpioset, or any sysfs path — those control the Pi board's ACT/PWR status LEDs, which are physically different chips.

Any color you set is saved to config.json on the device, so it survives reboot and is picked up on every transition into that state — including transitions driven by the OpenClaw lifecycle (Thinking ↔ Working) as you chat with the bot.

Prerequisites

  • The Intern is paired and connected to Wi-Fi (see Pairing & Wi-Fi).
  • Your laptop / phone is on the same Wi-Fi network as the device. The HTTP API binds to the LAN IP — it is not reachable from outside the local network.

Finding the device IP

Once the device is paired, ask it directly over your chat channel:

You: what's your IP?

Intern: I'm on 192.168.1.42. You can reach my API at http://192.168.1.42:5000.

Replace 192.168.1.42 with whatever the bot reports. Every example below uses $PI as a placeholder:

PI=192.168.1.42

If the chatbot is unreachable (e.g. you're still setting it up), you can also find the IP from your router's DHCP leases page — look for the hostname that starts with intern- or the MAC in the device's Wi-Fi info screen.

States

The ring has 8 lifecycle states. The HTTP API accepts these state values:

StateDefault visualWhen the device enters it
workingBlue breathing (2.5s)Ready / idle after setup, or after an agent reply finishes
thinkingCyan breathing (random 2-7s)OpenClaw lifecycle agent.start — agent is processing a message
idleDim blue 5%Entered only via explicit API call (no auto-fade after inactivity)
connectionmodeWhite blink 0.5sCaptive portal active — needs configuration
errorOrange-red solidOpenClaw lifecycle agent.error (auto-rolls back to Working after 10s)
bootingWhite solidDevice startup
workingnointernetAmber blink (1s)Network monitor detected internet loss
poweroffOffReset button 3s, or explicit API call

Most people only care about working — that's the state the ring sits in when nothing else is happening. If you change one color, change that one.

Endpoints

POST http://$PI:5000/api/led
POST http://$PI:5000/api/led/reset-all
GET http://$PI:5000/api/led

Header Content-Type: application/json is required on POSTs.

Quick examples

Just switch state

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working"}'

Set Working to solid red (saved)

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0}
}'

Set Working to red breathing 2s

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0},
"breathing_seconds":2
}'

Dim solid red for night mode

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":255,"g":0,"b":0},
"brightness":20
}'

Subtle blue breathing (always lit, 50-70%)

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{
"state":"working",
"color":{"r":0,"g":150,"b":255},
"breathing_seconds":3,
"brightness_min":50,
"brightness_max":70
}'

Turn the ring off (locked)

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"poweroff","inhibit":true}'

Release the lock

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working","inhibit":false}'

Reset a saved color for one state

curl -sS -X POST "http://$PI:5000/api/led" \
-H 'Content-Type: application/json' \
-d '{"state":"working","reset":true}'

Wipe every saved color

curl -sS -X POST "http://$PI:5000/api/led/reset-all"

Read the current state

curl -sS "http://$PI:5000/api/led"
# → {"state":"working"}

Request fields

FieldTypeRequiredNotes
statestringyesOne of the states in the table above.
color{r, g, b}no0-255 per channel. Persists to config.json.
breathing_secondsnumberno> 0 enables sine breathing on that period. Omit or ≤ 0 for solid.
brightnessnumbernoSolid mode only. Range 5-80, default 80.
brightness_minnumbernoBreathing mode only. Range 0-80, default 20.
brightness_maxnumbernoBreathing mode only. Range 0-80, default 80.
resetboolnotrue clears the saved color for state. Mutually exclusive with color (returns HTTP 400).
inhibitboolnotrue locks the ring at this state — useful only for poweroff. false releases a prior lock. Omit to leave the lock untouched.

Combination rules:

  • breathing_seconds > 0 → engine reads brightness_min / brightness_max, ignores brightness.
  • No breathing_seconds (or ≤ 0) → engine reads brightness, ignores brightness_min / brightness_max.
  • inhibit:true is only useful with poweroff; do not pair it with color changes.

Response shape

Success:

{
"state": "working",
"inhibit": false,
"color": {"r":0,"g":255,"b":0},
"breathing_seconds": 2,
"brightness_min": 20,
"brightness_max": 80
}

The response echoes the effective values after defaults have been applied. So if you POST without brightness_min / brightness_max, the response still shows 20 / 80 so you know what the engine is rendering.

Failure (HTTP 400):

{
"error": "unknown LED state \"banana\"",
"valid": ["booting","idle","connectionmode","thinking","working","workingnointernet","error","poweroff"]
}

Color presets

A small palette for quick experiments:

Namer,g,b
Red255, 0, 0
Orange255, 100, 0
Yellow255, 200, 0
Green0, 255, 0
Cyan0, 200, 255
Blue0, 100, 255
Purple180, 0, 255
Pink255, 0, 180
White255, 255, 255
Warm white255, 200, 150

The engine internally caps every channel at 80% to extend LED lifespan, so r:255 is the maximum allowed value for that channel, not a "blinding" brightness.

Breathing presets

Common breathing setups, expressed as ready-to-paste payload fragments:

Presetbreathing_secondsbrightness_minbrightness_max
Slow / chill42080
Normal22080
Fast0.82080
Subtle (always lit)25070
Soft pulse21040
Deep (off to full)2080

Tips

  • Override survives reboot. Once you POST with color, the choice is written to /root/config/config.json and reapplied on next boot. To revert, send the same state with reset:true (or hit /api/led/reset-all to wipe everything).
  • Override replaces the default animation. If you set thinking to red solid, the ring no longer renders the rotating cyan spot — only your solid red. Same for connectionmode (loses the blink) and any other state with a built-in animation.
  • The chat bot can drive the ring too. The led_control skill bundled with the device understands "change the led to red", "make it breathe slow blue", "turn off the light", etc. The skill posts to the same API.
  • No internet → still works. Because the API is purely local (5000 bound to the Wi-Fi IP), LED control keeps working even when the device is offline to the wider internet.

Troubleshooting

SymptomLikely causeFix
curl: (7) Failed to connect ...Wrong IP, or your laptop is on a different networkRe-ask the bot for IP; verify same Wi-Fi
HTTP 503 LED not availableLED engine failed to bind SPI (dev machine or no dtparam=spi=on)Reboot after setup.sh ran the SPI stage
HTTP 400 reset and color are mutually exclusiveSent both in the same requestSend reset:true first to wipe, or send color alone
HTTP 400 unknown LED stateMisspelled state nameUse a value from the valid array in the response
Color set OK but ring doesn't changeEngine is inhibited (locked) by a previous requestPOST any state with inhibit:false to release
Setting persists but lost after rebootEditing config manuallyAlways go through the API — manual edits are overwritten on Save()