Skip to main content

Setup

What happens between found it and it is in the project: pairing, codes, link buttons, and the list of things a bridge turns out to have on it.

A setup flow is the only part of a driver an installer experiences directly. Everything else is judged by whether the lights come on. This one is judged screen by screen, on a landing, on a phone, by somebody who has nine more devices to add — so the whole of this guide is one idea said several ways: do not ask for what you already know.

The shape​

setup is a pure function called once per step. It is handed the state it returned last time and whatever the installer typed, and it returns the next step:

fn setup(&self, driver_id: &str, state: &Value, input: &Args) -> (SetupStep, Value)

Nothing persists between calls except the Value you return, and the driver never touches a socket — Fetch and Session ask core to do that. So a flow is a small state machine, usually with a phase string in its state, and it is testable end to end without hardware: hand it a phase and a canned response and assert the step that comes back. Every flow in this repository is tested that way and yours should be.

StepWhat it does
InstructSay something, wait for Continue. Pressing a link button, plugging something in.
FormAsk for values.
PickChoose one row from a table — several were found and they differ in ways a dropdown cannot show.
ChooseOffer what was found, to adopt some or all of it.
FetchOne request, one response. Core owns the timeout and the retry.
SessionA connection held open across steps, for a protocol where the device speaks first.
MakeIdentityCore mints a client certificate for mutual TLS.
WaitNothing to do yet; come back in retry_ms.
DoneThese are confirmed. Adopt them.
FailedCould not continue, and why.

Do not ask for an address you were given​

Core hands the flow whatever the survey already found, in state:

let candidate = state
.get("mdns_candidates")
.and_then(Value::as_array)
.and_then(|all| all.first());

A device added from the Discovery list arrives with its address, its name, and its announcement already known. Showing an address form anyway — even prefilled — asks somebody to confirm a number they did not choose, cannot remember, and should never have to think about.

Keep the form as the path for a device nobody found. Multicast is blocked on plenty of networks and typing it in is a real fallback. It is just not the first screen.

An address is not an identity​

Every house is DHCP. An address is how to reach a device today.

This has a consequence drivers get wrong: never tell somebody to set a static IP. The controller follows a device that moves — it learns what the device announces about itself while it can see it, and rewrites the address when the lease changes. A driver that treats the address as identity breaks that, and pushes the cost onto whoever has to go and pin a reservation in a router they may not have the password for.

Store the address as a property. Let it be corrected. Match on what does not move: a serial number, a MAC, a resource id on a bridge.

Call it what it calls itself​

A television that has been set up announces the name somebody gave it. So does a bridge, a speaker, most streamers. That name is better than anything a driver can build, because a person chose it while looking at the thing:

label: state.get("found_name").and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("VIZIO TV ({address})")),

VIZIO TV (192.168.1.176) as a default is worse than it looks. Nobody has two televisions they tell apart by address, the number is wrong by next month, and it is the name that ends up in rules, scenes and everything somebody says out loud to the assistant.

One screen per decision​

Count the screens after a successful pairing. Each one must be a decision that has not already been made.

A flow here ended with "Add this TV — Paired" and a list containing one television, shown to somebody who had just pressed Add on that television, watched a code appear on it, and typed the code in. Three deliberate acts naming the same set, and then a question about which one they meant. Choose is for when a bridge has forty things on it and some of them are not wanted; SetupStep::done(vec![candidate]) is for everything else.

Say what is happening​

Wait exists so a flow can say waiting for the link button while it polls, and it takes a retry_ms. A greyed-out Next button with no explanation is indistinguishable from a hung driver — and the configurator draws a spinner for a step in progress, so use one.

A failure has to survive a retry​

Getting a code wrong is the single most likely thing to happen in a pairing flow, so treat the retry as a first-class path, not an error.

Two things it has to do. Clean up on the far side — a rejected pairing usually has to be cancelled before the device will start another, or the second attempt fails for a reason that has nothing to do with what the installer typed. And keep what you already knew:

let address = state.get("address").and_then(Value::as_str)
.or_else(|| input.get("address").and_then(Value::as_str))

Reading only from input is the bug it looks like. After the cancel, input holds the response to the cancel — so the retry announced that an address was needed, about a television whose address had never been in doubt.

Say what each device is, per device​

A Candidate carries capabilities, resolved against the proxy its manifest names:

Candidate {
label: name.to_string(),
driver_id: "signify.hue.light".into(),
capabilities: bulb_capabilities(light), // this fitting: dimmer, no color, 2200-4000K
..Default::default()
}

Use it rather than shipping a manifest per shape. Hue shipped five — bulb, bulb.color, bulb.dimmable, bulb.tunable, bulb.on_off — which shared every line of code and differed in one capability line each, so a company that sells one thing called a light appeared in the catalog as five things to install.

It has to be the contract rather than a screen. The resolved contract is what core validates commands against, so a white fitting on the same manifest as a color one can be sent set_color by a rule no matter what any UI chooses to draw.

The manifest's own declaration stays underneath as the base: put the constants there, and answer only for what varies between units.

Bring the rooms, the rules and the scenes​

A bridge somebody has been using is already commissioned. Every light on it is filed under a room, because they sat down and did that in the vendor's app.

  • Candidate::room — a suggestion. Nothing creates a room behind anybody's back; core matches or creates at the moment of adoption, with the list on screen.
  • ImportedRule — automations the far side already has. They arrive disabled and tagged with where they came from, because a driver's reading of somebody else's automation is a proposal, not something that starts running in a house at midnight.
  • ImportedScene — named arrangements, the same way.

Adopting a bridge with forty bulbs on it should be one press, not an afternoon of filing.

Adopting twice is how a device is refreshed​

Browsing an already-configured bridge is not an error and does not make duplicates. Core matches a candidate against the children already behind that bridge, using the properties the child manifest declares — Light id, not the bridge address every child inherits — and updates in place, keeping the binding ids and therefore the room, the rules and the scenes that point at them.

Two things follow. A resource that gained a capability, or was swapped for a different fitting in the same lampholder, reclassifies itself on the next browse. And a driver id may be changed without stranding anybody: re-adopt, and the device follows.

Report the inputs you turn out to have​

A manifest declares four HDMI ports because it describes a product line. The set in front of you has three and a jack. Answer current_inputs with HostCall::Connections and the real list replaces the guess for that one unit — which matters, because the pathfinder will otherwise route a room through a port that is not there.

Derive connection ids from something stable on the device — the input's own CNAME, not the order the list happened to arrive in. A project remembers what an installer wired by that number, so a firmware update that reorders the list must not move somebody's cabling.

Practical advice​

  • Test the flow, not the device. Hand setup a phase and a canned response; assert the step. Every branch — wrong code, cancelled, nothing found, blocked pairing — is a test, and they run in milliseconds without hardware.
  • Read your own screens out loud. Anything that says what the last screen said, or asks something the installer has already answered by their actions, comes out.
  • Fail with the next action in it. "Could not continue" is not a message. "The TV refused the code — press Add again and it will show a new one" is.
  • Do not put an address in a name. Not in a label, not in a default, not as a disambiguator. Two of the same device are told apart by where they are, which is what the room is for.