Anyone who has touched a glasshouse controller knows why swapping one temperature sensor for another model shows up on the quote as "redevelopment."
The reason is usually in the code. The program knows which sensor is wired to which relay. That correspondence table is scattered through the source, and changing a sensor changes the value range and the unit, so every place that knows the table has to be reworked. One component holds the whole system hostage.
This article is a record of building a greenhouse that has no such table at all. The nodes declare themselves, and the rules speak in kinds rather than parts. Once that works, you can swap a Celsius probe for a Fahrenheit model and add a CO2 sensor and an irrigation valve — and not one line of server code or rules changes. Below is the result of actually running it twice.
The result first — same code, different greenhouse
The original install. One Celsius thermometer, one hygrometer, one roof vent.

The house warms, the rule fires, and the vent opens.

Now replace the thermometer with another vendor's product. In Fahrenheit, too. And add a CO2 sensor and an irrigation valve. The server was not rebuilt and the rules are unchanged.


Note the last screen. The thermometer says 68.9 F in its own unit, while the rule log judged in Celsius: vent above 26C: 26.1 -> v1=80.0. The rule does not even know a Fahrenheit sensor is attached.
(68.9 F ≈ 20.5 °C, yet the rule fired at 26.1 °C. That is a matter of order — the vent opened at 26.1, and the open vent then cooled the house to the present 20.5. Which means the rule actually did work.)
The whole picture
greenhouse_bus (C) greenhouse_server (Dart · mcp_server) screen
nodes declare themselves ──RS-485──▶ asks what is there ──MCP──▶ renders what
t1 / h1 / c1 / v1 / w1 applies rules written by kind was discovered
The heart of it is what the server does not have. There is no table of which sensor corresponds to which relay. The server asks, and adapts to the answer.
① The node declares itself
Ask the bus what is on it, and each node answers with its own id, role, kind, model and unit.
if (strcmp(tool, "bus.list") == 0) {
tick_environment();
printf("{\"id\":%ld,\"ok\":true,\"result\":{\"nodes\":[", rid);
for (int i = 0; i < g_node_count; i++) {
Node *n = &g_nodes[i];
printf("%s{\"id\":\"%s\",\"role\":\"%s\",\"kind\":\"%s\","
"\"model\":\"%s\",\"unit\":\"%s\",\"value\":%.1f}",
i ? "," : "", n->id, n->role, n->kind, n->model,
n->unit, n->value);
}
printf("]}}\n");
}
What matters here is unit. If a node measures in Fahrenheit, it says Fahrenheit. It does not helpfully pre-convert the value for anyone. Hardware says honestly only what it knows, and the aligning happens above.
And a relay guards its own range itself.
} else if (strcmp(tool, "node.set") == 0) {
/* ... */
/* The relay clamps its own range. Whatever the rule upstream
* decided, the hardware still refuses to exceed itself. */
if (v < 0) v = 0;
if (v > 100) v = 100;
n->value = v;
Whatever the rules above decided, the hardware does not exceed itself. Safety has to be a property of the relay, not the goodwill of a rule.
② Exactly one place knows about units
The place that takes what a node declared and moves it into the canonical unit. In this sample, this is the only code that knows about units.
return (value - 32) * 5 / 9;
default:
return value;
}
}
Map<String, dynamic> toJson() => {
'id': id,
'role': role,
'kind': kind,
'model': model,
'unit': unit,
It does not branch on the model name. Written as if (model == 'FX-200'), this file would have to be opened every time a new sensor is bought. Look at unit and a product you've never seen aligns automatically.
③ Rules are written by kind, not by part
Exactly the way a grower talks. "Above 26 degrees, open the vent."
/// A growing rule — the way a grower says it.
///
/// "When the house goes above 26 degrees, open the vent." Notice what this
/// rule does not say. It does not say which sensor, which relay, or what the
/// model number is. It states only the *kind* of value it reads and the
/// *kind* of thing it moves, so it survives the hardware underneath being
/// replaced.
const houseRules = <Rule>[
Rule(
name: 'vent above 26C',
whenKind: 'temperature',
above: 26.0,
thenKind: 'vent',
setTo: 80.0,
elseSetTo: 0.0,
),
Rule(
name: 'water below 60% humidity',
whenKind: 'humidity',
above: 60.0,
thenKind: 'valve',
setTo: 0.0,
elseSetTo: 40.0,
),
];
When a rule decides, it does not look for a part either. It looks for something of the matching kind.
RuleDecision? decide(List<BusNode> nodes) {
final sensor = nodes.where((n) => n.isSensor && n.kind == whenKind);
final actuator = nodes.where((n) => n.isActuator && n.kind == thenKind);
if (sensor.isEmpty || actuator.isEmpty) return null;
final reading = sensor.first.canonicalValue;
final fired = reading > above;
return RuleDecision(/* ... */);
}
Please note the return null. That is when the thing a rule would move is not in the house. This is not an error. A grower can order a CO2 sensor and write the rule before it arrives, and that rule should rest quietly until the sensor comes. The server does not hide this; it writes it on the screen — idle: water below 60% humidity in the first greenhouse capture. It means the irrigation rule is idle for want of a valve, and the grower is entitled to know that.
④ The run log
The log from running both installs back to back. Verbatim output of the run.
[+ 10ms] === pass A: original install (Celsius probe) ===
[+ 1133ms] A: server up with install [t1:temperature:TH-100:C h1:humidity:HM-20:pct v1:vent:VT-9]
[+ 1542ms] A: discovered 3 nodes — t1:temperature:TH-100(C), h1:humidity:HM-20(pct), v1:vent:VT-9(pct)
[+ 4622ms] A: 1 applied · idle: water below 60% humidity
[+ 4622ms] A: vent above 26C: 26.5 -> v1=80.0
[+ 4682ms] A: actuators now v1=80.0
[+ 4684ms] === pass B: probe swapped for a Fahrenheit model, two nodes added ===
[+ 4684ms] server code: unchanged rules: unchanged rebuild: none
[+ 5773ms] B: server up with install [t1:temperature:FX-200:F h1:humidity:HM-20:pct c1:co2:CO-5:ppm v1:vent:VT-9 w1:valve:WV-3]
[+ 5914ms] B: discovered 5 nodes — t1:temperature:FX-200(F), h1:humidity:HM-20(pct), c1:co2:CO-5(ppm), v1:vent:VT-9(pct), w1:valve:WV-3(pct)
[+ 9163ms] B: 2 rules applied
[+ 9164ms] B: vent above 26C: 26.3 -> v1=80.0 · water below 60% humidity: 62.4 -> w1=0.0
[+ 9275ms] B: actuators now v1=80.0, w1=0.0
Please compare these two lines.
A: vent above 26C: 26.5 -> v1=80.0 (TH-100, Celsius)
B: vent above 26C: 26.3 -> v1=80.0 (FX-200, Fahrenheit)
Same rule, same threshold, different hardware. No human edited code in between. Had unit handling not been gathered into one place, B would have compared the Fahrenheit value 79 against 26 and opened the vent every time.
The build passes like this.
$ cc -O2 -o greenhouse_bus greenhouse_bus.c -lm
$ dart analyze # greenhouse_server
No issues found!
$ flutter analyze # greenhouse_app
No issues found!
$ flutter test test/capture_test.dart
00:09 +1: All tests passed!
Measurements, and what was not measured
| Value | |
|---|---|
| Bus scan round trip (5 nodes) | about 50 ms (from the bus.state call to state reflected, log range 5914→5956) |
| One rule application (2 scans + 2 actuator writes included) | about 440 ms |
| Full two-install verification | 9.3 seconds |
These figures were measured against a simulated bus. Nodes on a real RS-485 line are governed by line speed and polling interval, so the order of magnitude may differ. Nothing was measured on a real line — numbers that were not measured do not get written as measurements.
What was not measured. Behaviour when a node does not answer (a 2-second timeout is in place, but an actual broken line was not tested), the cost of scanning when nodes grow to dozens, and priority when rules conflict — all three are outside this sample.
Caught while building — a race that nearly passed as "it works sometimes"
Attaching the verification script and running it failed. But running the same test by hand passed. A few more tries and it worked sometimes and not others.
It was fortunate that this was not filed under "environment." The verification script was failing without leaving a reason, so the first fix was to make it write the test output to a file. Then the cause was on the first line.
McpError (-32100): Resource not found: ui://grower
An ordering problem in my own server code. Registering the screen resource happened after the bus scan finished.
// the order that was wrong
Future<void> register() async {
_nodes = await bus.scan(); // ← hardware round trip. A client attaching in here
_registerScreen(); // ← finds the screen does not exist yet
_registerTools();
}
A client that connects within that round trip and requests ui://grower gets "no such resource." Fast hardware makes the window narrow and it passes; slow hardware fails. The corrected form:
final transport = McpServer.createStdioTransport().get();
server.connect(transport);
await Completer<void>().future;
}
class Slot {
Reduced to one principle: nothing a client can ask for may depend on a hardware response. An empty node list before the scan is fine — that is the fact at that moment. A screen that does not exist is a different matter.
After the fix it passed three times in a row. Had the verification script not left a failure log, this defect would still be sitting there with the label "intermittent."
This content requires Developer or above
Sign in and upgrade your plan to continue reading.
View Plans