유리온실 제어기를 한 번이라도 손대 본 사람은 안다. 온도 센서 하나를 다른 모델로 바꾸는 일이 왜 견적서에 "재개발"로 잡히는지.
이유는 대개 코드 안에 있다. 어느 센서가 어느 릴레이에 물려 있는지를 프로그램이 알고 있다. 그 대응표가 소스 곳곳에 흩어져 있고, 센서를 바꾸면 값의 범위가 달라지고 단위가 달라지고, 그러면 그 표를 아는 모든 곳을 다시 손봐야 한다. 부품 하나가 시스템 전체를 인질로 잡는다.
이 글은 그 대응표를 아예 갖지 않는 온실을 만들어 본 기록이다. 노드가 자기를 선언하고, 규칙은 부품이 아니라 종류로 말한다. 그게 되면 섭씨 센서를 화씨 모델로 갈아 끼우고 CO2 센서와 관수 밸브를 증설해도 — 서버 코드와 규칙은 한 줄도 바뀌지 않는다. 아래는 그걸 실제로 두 번 돌려 본 결과다.
결과부터 — 같은 코드, 다른 온실
처음 설치. 섭씨 온도계 하나, 습도계 하나, 천창 하나.

온실이 데워지고 규칙이 발동해 천창이 열렸다.

이제 온도계를 다른 회사 제품으로 교체한다. 단위도 화씨다. 그리고 CO2 센서와 관수 밸브를 증설한다. 서버는 다시 빌드하지 않았고 규칙도 그대로다.


마지막 화면을 짚어 둔다. 온도계는 68.9 F 라고 자기 단위로 말하고 있는데, 규칙 로그는 vent above 26C: 26.1 -> v1=80.0 이라고 섭씨로 판단했다. 규칙은 화씨 센서가 붙은 줄도 모른다.
(68.9 F ≈ 20.5 °C 인데 규칙은 26.1 °C 에서 발동했다. 순서 때문이다 — 26.1도에서 천창이 열렸고, 그 뒤 열린 천창이 온실을 식혀서 지금 20.5도다. 규칙이 실제로 일을 했다는 뜻이다.)
전체 그림
greenhouse_bus (C) greenhouse_server (Dart · mcp_server) 화면
노드가 자기를 선언 ──RS-485──▶ 무엇이 있는지 묻는다 ──MCP──▶ 발견된 것을
t1 / h1 / c1 / v1 / w1 종류로 쓴 규칙을 적용 그대로 렌더
핵심은 서버가 갖지 않는 것에 있다. 어느 센서가 어느 릴레이에 대응하는지의 표가 없다. 서버는 묻고, 답에 맞춘다.
① 노드가 자기를 선언한다
버스에 무엇이 있냐고 물으면, 노드마다 자기 id·역할·종류·모델·단위를 답한다.
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");
}
여기서 중요한 건 unit 이다. 노드는 자기가 화씨로 재는 물건이면 화씨라고 말한다. 값을 남 좋으라고 미리 변환해 주지 않는다. 하드웨어는 자기가 아는 것만 정직하게 말하고, 맞추는 일은 위에서 한다.
그리고 릴레이는 자기 범위를 자기가 지킨다.
} 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;
위에서 규칙이 무엇을 결정했든, 하드웨어는 자기를 넘어서지 않는다. 안전은 규칙의 선의가 아니라 릴레이의 성질이어야 한다.
② 단위를 아는 곳은 딱 한 군데
노드가 선언한 걸 받아 정규 단위로 옮기는 자리다. 이 샘플에서 단위를 아는 코드는 여기뿐이다.
/// 규칙이 쓰는 단위로 옮긴 값.
///
/// 이 샘플에서 단위를 아는 곳은 여기 하나뿐이고, 모델명이 아니라 노드가
/// 선언한 unit 을 보고 판단한다. 아무도 들어 본 적 없는 센서라도 자기 단위를
/// 말하므로 여기로 정확히 들어온다.
double get canonicalValue {
switch (unit) {
case 'F':
return (value - 32) * 5 / 9;
default:
return value;
}
}
모델명으로 분기하지 않는다. if (model == 'FX-200') 로 짰다면 다음 센서를 살 때마다 이 파일을 열어야 한다. unit 을 보면 처음 보는 제품도 자동으로 맞는다.
③ 규칙은 부품이 아니라 종류로 쓴다
농부가 말하는 방식 그대로다. "26도 넘으면 천창 열어라."
/// 재배 규칙 — 농부가 말하는 방식 그대로.
///
/// "온실이 26도를 넘으면 천창을 연다." 이 규칙이 말하지 않는 것을 봐 달라.
/// 어떤 센서인지, 어떤 릴레이인지, 모델 번호가 무엇인지 말하지 않는다.
/// 읽는 값의 *종류*와 움직일 것의 *종류*만 말하므로, 아래 하드웨어가
/// 교체돼도 그대로 살아남는다.
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,
),
];
규칙이 판단할 때도 부품을 찾지 않는다. 종류가 맞는 것을 찾는다.
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(/* ... */);
}
return null 을 눈여겨봐 주기 바란다. 규칙이 움직일 물건이 온실에 없을 때다. 이건 오류가 아니다. 농부가 CO2 센서를 주문해 놓고 도착 전에 규칙부터 써 둘 수 있고, 그러면 그 규칙은 센서가 올 때까지 조용히 쉬어야 한다. 서버는 그걸 숨기지 않고 화면에 적는다 — 첫 번째 온실 캡처의 idle: water below 60% humidity 가 그것이다. 밸브가 없으니 관수 규칙은 놀고 있다는 뜻이고, 농부는 그 사실을 알 자격이 있다.
④ 실행·검증 로그
두 설치를 연달아 돌린 로그다. 실행이 출력한 원문이다.
[+ 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
두 줄을 대조해 주기 바란다.
A: vent above 26C: 26.5 -> v1=80.0 (TH-100, 섭씨)
B: vent above 26C: 26.3 -> v1=80.0 (FX-200, 화씨)
같은 규칙, 같은 임계값, 다른 하드웨어. 그 사이에 사람이 고친 코드는 없다. 만약 단위 처리가 한 군데로 모여 있지 않았다면 B 는 화씨 값 79 를 26 과 비교해 매번 천창을 열었을 것이다.
빌드는 이렇게 통과한다.
$ 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!
실측치와, 재지 못한 것
| 값 | |
|---|---|
| 버스 스캔 왕복 (5노드) | 약 50 ms (bus.state 호출부터 상태 반영까지, 로그 5914→5956 구간) |
| 규칙 1회 적용 (스캔 2회 + 액추에이터 쓰기 2회 포함) | 약 440 ms |
| 전체 2회 설치 검증 | 9.3 초 |
이 수치들은 시뮬 버스를 상대로 잰 것이다. 실제 RS-485 라인 위의 노드는 회선 속도와 폴링 주기에 지배되므로 자릿수가 다를 수 있다. 실선로에서는 재지 않았다 — 재지 않은 숫자를 실측처럼 적지 않는다.
재지 못한 것. 노드가 응답하지 않을 때의 동작(타임아웃 2초는 걸어 뒀으나 실제 단선 상황은 시험하지 않았다), 노드가 수십 개로 늘었을 때의 스캔 비용, 규칙이 서로 충돌할 때의 우선순위 — 셋 다 이 샘플의 범위 밖이다.
만들다 걸린 것 — "가끔 되는데?"로 넘길 뻔한 경합
검증 스크립트를 붙이고 돌렸더니 실패했다. 그런데 손으로 같은 테스트를 돌리면 통과했다. 몇 번 더 해 보니 어떤 때는 되고 어떤 때는 안 됐다.
여기서 "환경 탓"으로 넘기지 않은 게 다행이었다. 검증 스크립트가 실패해도 이유를 안 남기고 있길래 테스트 출력을 파일로 남기도록 먼저 고쳤다. 그러자 원인이 첫 줄에 있었다.
McpError (-32100): Resource not found: ui://grower
내 서버 코드의 순서 문제였다. 화면 리소스를 등록하는 일이 버스 스캔이 끝난 뒤에 있었다.
// 문제가 있던 순서
Future<void> register() async {
_nodes = await bus.scan(); // ← 하드웨어 왕복. 이 사이에 클라이언트가 붙으면
_registerScreen(); // ← 화면은 아직 존재하지 않는다
_registerTools();
}
클라이언트가 그 왕복 시간 안에 접속해서 ui://grower 를 요청하면 "그런 리소스 없다"는 답을 받는다. 하드웨어가 빠르면 창이 좁아 통과하고, 느리면 실패한다. 고친 형태는 이렇다.
// 화면과 도구를 먼저 공개하고, 연결을 받고, 그다음에 하드웨어를 본다.
greenhouse.register(); // 동기 — 아무것도 기다리지 않는다
final transport = McpServer.createStdioTransport().get();
server.connect(transport);
await greenhouse.warmUp(); // 이제 무엇이 설치돼 있는지 알아본다
원칙 한 줄로 정리하면 이렇다. 클라이언트가 물어볼 수 있는 것은 무엇 하나 하드웨어의 응답에 의존해서는 안 된다. 아직 스캔 전이라 노드 목록이 비어 있는 건 괜찮다 — 그 순간의 사실이니까. 화면 자체가 없는 건 다르다.
고친 뒤 3회 연속 통과했다. 검증 스크립트가 실패 로그를 남기지 않았다면 이 결함은 "간헐적"이라는 이름표를 달고 남아 있었을 것이다.