Ground truth for Shelly Duo GU10 RGBW smart bulb (Gen1): LAN HTTP REST contract, mDNS discovery (with the non-loopback-IPv4-bind gotcha), color/temp endpoints, off semantics, latency expectations. Language-agnostic facts; Kotlin/Ktor reference example.
88
86%
Does it follow best practices?
Impact
100%
1.28xAverage score across 3 eval scenarios
Passed
No known issues
The Shelly Duo GU10 is a LAN-controllable RGBW bulb with a simple, undocumented-but-stable HTTP API. Sub-100 ms latency on local WiFi makes it the IoT-counterpart-of-choice for high-rate producers (compare to Govee cloud at ~1 s).
http://<bulb-ip> — no HTTPS, no auth by default.GET /color/0?turn={on|off}&red=R&green=G&blue=B&gain=N
GET /white/0?turn={on|off}&temp=K&brightness=N
GET /color/0?turn=off (or /white/0?turn=off)GET /status → JSON with lights[0].ison, wifi_sta.ip, rssi, macGET /shelly → JSON with type, mac, fw_verFor debounce controllers, min-interval = 0.2 s is the right starting point (compare to 1.2 s for Govee cloud). The bulb will happily accept ~5 req/s. Higher is wasteful, not broken.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
class ShellyBulb(private val ip: String) {
private val client = HttpClient(CIO)
suspend fun setColor(r: Int, g: Int, b: Int, gain: Int = 100) {
client.get("http://$ip/color/0") {
parameter("turn", "on")
parameter("red", r)
parameter("green", g)
parameter("blue", b)
parameter("gain", gain)
}
}
suspend fun setWhite(tempK: Int = 4750, brightness: Int = 100) {
client.get("http://$ip/white/0") {
parameter("turn", "on")
parameter("temp", tempK)
parameter("brightness", brightness)
}
}
suspend fun off() {
client.get("http://$ip/color/0?turn=off")
}
suspend fun isReachable(timeoutMs: Long = 1500): Boolean = runCatching {
withTimeout(timeoutMs) { client.get("http://$ip/status").status.value == 200 }
}.getOrDefault(false)
}Discovery uses javax.jmdns.JmDNS. The default JmDNS.create() binds to the JVM's default address, which on macOS is localhost (127.0.0.1) — and discovery returns nothing.
Bind to the primary non-loopback IPv4 interface explicitly:
import javax.jmdns.JmDNS
import javax.jmdns.ServiceInfo
import java.net.InetAddress
import java.net.NetworkInterface
fun primaryIPv4(): InetAddress {
val candidates = NetworkInterface.getNetworkInterfaces().asSequence()
.filter { it.isUp && !it.isLoopback && !it.isVirtual && !it.displayName.startsWith("utun") }
.flatMap { it.inetAddresses.asSequence() }
.filter { !it.isLoopbackAddress && it.address.size == 4 }
.toList()
return candidates.firstOrNull()
?: error("No non-loopback IPv4 interface found. Are you offline?")
}
fun discoverShelly(timeoutMs: Long = 4000): List<String> {
val jmdns = JmDNS.create(primaryIPv4()) // <-- THIS is the load-bearing argument
val services: Array<ServiceInfo> = jmdns.list("_http._tcp.local.", timeoutMs)
return services
.filter {
it.name.startsWith("shellycolorbulb-") ||
it.name.startsWith("shellybulbduo-")
}
.flatMap { it.inet4Addresses.map { addr -> addr.hostAddress } }
.also { jmdns.close() }
}
// usage
val ips = discoverShelly() // e.g., ["192.168.8.135"]Gradle dependency: implementation("org.jmdns:jmdns:3.6.0").
For a stable demo setup, reserve the bulb's IP in your router (DHCP reservation by MAC) and hardcode the IP:
val bulbIp = System.getenv("SHELLY_BULB_IP") ?: "192.168.8.135"mDNS adds 4 s of cold-start latency, a dependency (jmdns), and a venue-network failure mode (some conference WiFi blocks multicast). For production agents, mDNS-with-fallback is the pattern. For demos, static IP is one less thing to break.
Unlike Govee, Shelly does not have the rgb=(0,0,0) no-op problem. turn=off reliably extinguishes the bulb and the bulb retains its last color/gain for the next turn=on. You can use turn=off freely.
On script shutdown:
Runtime.getRuntime().addShutdownHook(Thread {
runBlocking { bulb.off() }
})JmDNS.create() with no argument on macOS — silently binds to localhost./white/0 after /color/0 without an explicit color reset — mode transitions can leave the bulb in a stale state.# Reachability
curl --max-time 2 "http://192.168.8.135/status" | jq .lights[0].ison
# Set red, sit for 2s, off
curl "http://192.168.8.135/color/0?turn=on&red=255&green=0&blue=0&gain=100"
sleep 2
curl "http://192.168.8.135/color/0?turn=off"
# List network services on the LAN (macOS):
dns-sd -B _http._tcp local.