히스토리

  • [2026-03-09 Mon 10:20] @junghan — DS #7 배터리 교체 KEEPALIVE 엣지케이스 D-DAY. P0 정상 동작 확인, 15분 갭 원인 확정. 코드 수정 전 넓게 보는 중.
  • [2026-03-09 Mon 10:20] @junghan — 위대한 해결을 앞두고 있습니다.
  • [2026-03-07 Sat 13:14] 생성 — fxf-uho-mvt DS 페어링 프로파일러 설계 과정에서, 서스먼 SDF 원칙이 임베디드 Zig 코드에 적용된 실례 기록

SDF × Zig: 유연한 상태머신이 임베디드에서 만나는 까다로운 문제

문제: “20대 DS를 동시에 페어링하면 터진다”

Zigbee Door Sensor(TS0203) 20대를 허브에 연속 페어링하면:

  1. 9대째부터 MAC=FFFFFFFFFFFFFFFF (SDK Address Table 8칸 포화)
  2. 5대 동시 투입 시 3/5가 Early-leave (DS 펌웨어 특성, 16초 후 자발적 이탈)
  3. leave된 DS가 rejoin 시도 → permit_join 닫혀있으면 영구 죽음
  4. bind 실패 시 서버 등록 불가 → 좀비 디바이스

이것은 단순 코드 버그가 아니다. SDK 테이블 크기, DS 펌웨어 타이밍, Zigbee 프로토콜 특성, 서버 등록 플로우가 모두 엮인 *시스템 문제*다.

서스먼이 말하는 유연성 (SDF 1장)

프로그래머들은 시간에 쫓기는 나머지, 어쩔 수 없이 제한된 용도로만 사용 가능한, 성장의 여지가 거의 없는 코드를 작성한다. 그로 인해 과거의 자신이 쌓은 벽에 가로막혀 코드를 새로 수정해야 하는 상황이 벌어지기도 한다. — 제럴드 제이 서스먼, 소프트웨어 디자인의 유연성 (SDF) (해럴드 에이블슨, 제럴드 제이 서스먼, and 줄리 서스먼 2016)

이 프로젝트가 정확히 그 상황이었다:

  • RexBee SDK: 클로즈드 소스, Address Table 8칸 하드코딩
  • DS 펌웨어: 벤더 제공, NVM reporting interval 때때로 리셋
  • 우리 코드: SDK 위에서 “이미 잘 동작하겠지” 가정하고 작성

결과: 14대까지는 완벽, 15대째부터 무너짐. 과거의 가정이 쌓은 벽.

SDF 원칙의 Zig 실전 적용

원칙 1: 중복성과 축중성 (SDF 1.3)

생물학 시스템은 하나의 기능을 여러 경로로 달성한다. DS 페어링에서:

  • bind 실패 → data_report에서 재시도 (다른 경로)
  • configureReporting 실패 → 15분 후 KEEPALIVE 미수신으로 감지 (다른 감각)
  • NVM 리셋 감지 → readReportingConfig(F2)로 직접 확인 (능동 진단)
  • Early-leave → permit_join 재오픈 + DS 풀 리셋 (자가 복구)

하나의 실패가 전체 실패가 되지 않는 축중적 설계(degenerate design).

원칙 2: 탐색 행동 (SDF 1.4)

시스템이 환경을 *능동적으로 탐색*한다:

부팅 → readReportingConfig(F2)
     → maxInterval = 900? → OK (NVM 유효)
     → maxInterval ≠ 900? → configured=false → 재설정 트리거
 
30초마다 → device_table_used / device_table_max
        → 80%? → WARN
        → 90%? → 페어링 거부 + 죽은 entry 정리
 
KEEPALIVE 미수신 → 디바이스 건강 재평가
                → configured=false → bind/configure 재실행

“수정해줬다”는 벤더 말만 믿지 않는다. 허브가 *스스로 읽어내고, hot하면 유연하게 조절*한다.

원칙 3: 계층화 (SDF 6장)

각 계층은 자기 관심사만 처리:

Layer 0: C FFI (rexbee_profiler.c)
  → SDK API 호출, raw 데이터 수집
 
Layer 1: Zig 바인딩 (rexbee_profiler.zig)
  → C 구조체 → Zig StackProfile 변환
 
Layer 2: Health 판정 (순수 함수)
  → assessHealth(profile) → green/yellow/red
  → 테스트 가능, 결정론적
 
Layer 3: 대응 (transition)
  → HubState.stack_health 반영
  → 페어링 거부 / 알림 / 자동 복구

EmberZNet 오픈소스(gecko_sdk)의 카운터 시스템과 같은 사상:

  • MAC TX/RX 성공/실패 카운터
  • APS 레이어 카운터
  • Neighbor Added/Removed 이벤트
  • Buffer Allocation Failure

RexBee SDK에도 존재함을 발견:

  • stkGetSystemDiagosisCounterPointer() — 전체 진단 카운터
  • app_get_diagnosis_critical_counter_*() — TX/RX/CCA fail
  • app_user_{1,2,3}_increase_sys_counter() — 커스텀 카운터

원칙 4: 전파 (SDF 7장)

전파 네트워크(Propagator Network)의 핵심: 부분 정보로도 추론한다.

정보 1: device_table_used = 19 (SDK 보고)
정보 2: hub_state.json에 등록된 DS = 20대
정보 3: child_iterate로 실제 살아있는 child = 17대
 
→ 3개 정보가 불일치
→ 유령 디바이스 3대 존재 추정
→ NV iterate로 확인 → 죽은 entry 정리

하나의 정보만으로 결정하지 않는다. 여러 소스에서 온 부분 정보를 *병합(merge)*해서 진실에 수렴.

핵심: DS 20대 연속 페어링 프로파일러

이 프로파일러의 유일한 목적: DS 20대를 안전하게 받아내는 것.

페어링 단계별 프로파일 체크

단계체크 항목행동
페어링 시작 전device_table 여유분 ≥ 5부족하면 죽은 entry 정리
배치 5대 투입 후child_count 변화기대값과 비교
Early-leave 감지leave 카운트permit_join 재오픈 + 풀 리셋
배치 완료 후bind 성공률실패 DS 재시도 큐
KEEPALIVE 전환configured=true 수렴15분 내 미수렴 시 알림

포화 방어 로직

device_table_max = 64 (벤더 수정 후)
device_table_used = current
 
if (max - used) < 5:
    → 페어링 거부
    → child_iterate로 dead entry 탐색
    → dead entry 발견 시 정리 후 재시도
 
if (max - used) < 2:
    → 긴급 모드: 모든 페어링 중단 + Shadow 알림

자가 복구 경로

모든 상태에서 정상으로 돌아올 경로 (인바리언트):

Address Table 포화 → dead entry 정리 → 여유 확보 → 페어링 재개
Early-leave → permit_join 재오픈 → DS rejoin → bind → configured
NVM 리셋 → F2 감지 → configured=false → reconfigure
bind 실패 → data_report에서 재시도 → 자연 수렴

무한 대기, 무한 루프, 복구 불가 상태 없음.

왜 오픈소스가 아니면 안 되는가

이번 DS 작업의 절반은 *SDK와 싸우는 시간*이었다:

  • Address Table 8칸 → 바이너리 파싱으로 발견 (4시간)
  • config.c 구조체 정의 없음 → 컴파일 불가
  • 벤더에 질문 10개 → 응답 대기

EmberZNet 오픈소스(gecko_sdk)에는 모두 있었다:

  • ember-configuration-defaults.h — 모든 기본값
  • address-table.h — Add/Remove/Lookup API
  • counters.h — 42종 진단 카운터
  • diagnostic-server-soc.c — ZCL Diagnostics Cluster

All programs have bugs, even ones that meet given specs (because the specs are always incomplete or inconsistent). Thus, it is more effective to make systems that are debuggable than to try to make systems that are correct by construction. — Gerald Jay Sussman, Scheme’22 Keynote

디버거블한 시스템 = 오픈소스 스택. homeagent-config에서는 풀 오픈소스로 간다.

에이전틱 엔지니어링과 임베디드의 교차점

AI 에이전트가 코드를 생성하는 시대에도, 이 문제는 까다롭다:

  1. *바이너리 역공학*이 필요 (SDK .a에서 심볼/값 추출)
  2. *프로토콜 이해*가 필요 (ZCL, ZDP, Zigbee join/leave 시퀀스)
  3. *타이밍 분석*이 필요 (DS가 16초 후 leave하는 패턴)
  4. *현장 로그 해석*이 필요 (0xBE가 Address Table 포화인지, permit_join 거부인지)

이것은 “코드를 짜라”가 아니라 “시스템을 이해하라”. 서스먼이 말한 “프로그래밍은 코딩이 아니다”의 임베디드 버전.

유연한 상태머신은 이 이해를 *코드로 결정화*한 것이다. 환경이 바뀌어도(SDK 업데이트, DS 펌웨어 변경, 테이블 크기 조정) 허브가 스스로 감지하고 적응하는 — 서스먼식 유연성의 Zig 실전.

관련 노트

DS #7 배터리 교체 — “F2가 판사, bind는 최후 수단”

실증: 수동 트리거 → P0 발동 → 15분 복구

20대 DS 연속 페어링 성공 후, 배터리 탈부착 테스트에서 DS #7(W_0000004314)만 KEEPALIVE 미수신. 13시간 방치 후 출근하여 문을 손으로 누르니:

09:43:22  P0 발동: "46737454ms 미수신 → configured=false 리셋"
09:43:26  ACTION 발행 (IAS Zone) — KEEPALIVE는 안 나감
  ... 15분 ...
09:58:22  배터리 보고 도착 (cluster 0x0001, BatteryPercentage=100)
09:58:24  KEEPALIVE 발행! battery=96 ← 복구
10:13:26  KEEPALIVE 발행! battery=97 ← 15분 주기 정상

P0 방어 로직은 정상 동작*했다. 문제는 P0이 configured=false로 리셋한 뒤에도 배터리 보고가 15분 뒤에야 와서 그 동안 KEEPALIVE가 나가지 않는 *갭.

근본 원인: KEEPALIVE 트리거 = 배터리 보고만

현재 코드에서 needs_keepalive=truegenPowerCfg(0x0001) 수신 시에만 설정. IAS Zone(0x0500)은 ACTION만 발행하고 KEEPALIVE는 트리거하지 않는다.

배터리 교체 시 DS가 보내는 것:

  1. IAS Zone: 문닫힘(Alarm1=0) + 뚜껑열림(Tamper=1) ← 항상 온다
  2. genPowerCfg: 배터리 100% ← 안 올 수 있다

z2m은 모든 메시지에서 lastSeen을 갱신하지만, 그건 서버에 KEEPALIVE를 쏘는 게 아니라 내부 변수 대입일 뿐(비용 제로). 우리는 MQTT 발행이니까 최소한으로 해야 한다.

해결 전략: Tamper=1+Alarm1=0 → KEEPALIVE + F2

배터리 교체 감지 (Tamper=1 + Alarm1=0):
  needs_keepalive = true       → KEEPALIVE 즉시 (15분 갭 제거)
  reporting_checked = false    → F2 재실행 (NVM 검증)
  bind/report 재설정 안 함!   → F2가 판단

F2가 판사. readReportingConfig(ZCL Read)는 Address Table/Binding Table을 소모하지 않는다. F2=900이면 NVM 유효 → bind 안 함. F2≠900이면 NVM 리셋 → 기존 reconfigure 로직이 처리.

오탐(뚜껑 열고 문 닫기)이어도 피해는 KEEPALIVE 1회 + F2 1회 = 무해. bind를 안 부르니까 SDK 테이블 소모 없음 — #118 이중 bind 재발 방지.

넓게 보면서 좁게 수정한다

코드 수정은 단 한 줄이라도 삭혀서 한다. 20대가 돌아가는 프로덕션에 넣는 것이니까. ds.zig IAS Zone 파싱 2곳(0x0500 바이너리 + TSL JSON)에 조건 추가. 테스트 → 빌드 → 배포 → 배터리 탈착 검증.