# IR 광신호 Mock 프로토타입 설계 정리

**대상 과제:** IR 광신호 기반 무선 토양수분 센서 (농촌진흥청 국립원예특작과학원)
**본 문서 범위:** 격자 지점별 LED 블링크 횟수를 카메라로 카운트하는 벤치 프로토타입
**작성 기준일:** 2026-08-22
**개정:** rev.2 — 온·습도 비트 인코딩 → **습도 단일 값, 블링크 카운트 방식**으로 변경
**구현 상태:** 7장의 펌웨어가 저장소에 구현되고 빌드 검증까지 완료됨(2026-08-22). CubeMX 생성 결과와 설계 간 불일치 2건을 구현 중 발견·수정 — [6.7절](#67-cubemx-생성-결과-검증-중-발견된-오차) 참조.

---

## 1. 프로토타입 목표

각 식물 팟에 습도 센서가 내장되고, 그 값을 IR LED 광신호로 변환해 외부 카메라가 판독하는 시스템을 검증한다.

**본 단계에서 검증하는 것은 값의 의미가 아니라 계수(計數)의 신뢰성이다.**

- 실제 습도값은 사용하지 않는다
- 온도는 다루지 않는다
- 카메라가 **격자 각 지점의 깜박임 횟수를 정확히 세는지**만 확인한다

배터리 수명, 방수, 양산성은 본 단계에서 고려하지 않는다.

### 1.1 카운트 방식을 채택한 의의

비트 인코딩 대비 이점이 분명하다.

| 항목 | 비트 인코딩 | 블링크 카운트 |
|---|---|---|
| 프레임 정렬 | 심볼 경계 판정 필요 | **불필요** |
| 임계값 | 비트마다 판정 | 지점마다 1회 |
| 프레임 누락 | 심볼 전체 손실 | 상태 유지 구간이 길어 영향 미미 |
| 검증 대상 | 광학계 + 프로토콜 혼재 | **광학계만 순수 분리** |

이번 단계에서 알고 싶은 것은 "카메라가 9개 지점을 안정적으로 분리 해상하고 각 지점의 시간적 상태를 추적할 수 있는가"이다. 카운트 방식은 프로토콜 계층을 걷어내고 이 질문에만 답한다.

또한 **고정 창(window) 내의 카운트는 곧 주파수**이므로, 제안서의 1~6Hz 주파수 변조 방식으로 자연스럽게 확장된다. 버린 설계가 아니라 하위 단계다.

---

## 2. 확정된 설계 결정

| 항목 | 결정 | 근거 |
|---|---|---|
| MCU 보드 | NUCLEO-L073RZ (STM32L073RZ) | 기 확보 |
| LED | Siemens LD274 (950nm) × 9 | 기 확보, 추후 변경 예정 |
| 카메라 필터 | 950nm, C 마운트 | LD274 피크 파장과 정합 |
| 셔터 방식 | 글로벌 셔터 | 확인 완료 |
| 격자 구성 | 3×3, 9개 지점 | 각 지점이 팟 1개에 대응 |
| 전송 방식 | **지점별 블링크 카운트** | 본 개정 |
| 구동 방식 | GPIO 오픈 드레인 직결 | 근거리라 저전류로 충분 |
| 테스트 거리 | 1 ~ 1.5 m | 블롭 분리 여유 확보 |

### 2.1 기본 카메라 필터 제거 건

카메라에 기본 장착돼 있던 필터(IR-cut 추정)를 제거하고, 별도 950nm 필터를 C 마운트로 장착했다. 제거한 기본 필터는 이후 설계와 무관하다.

---

## 3. LED 특성 (LD274) 및 그 함의

### 3.1 데이터시트 주요 값

| 항목 | 값 |
|---|---|
| 피크 파장 λ_peak | 950 nm |
| 스펙트럼 폭 (50%) | 55 nm |
| **반각 φ** | **±10°** |
| 방사강도 I_e (I_F = 100 mA) | 최소 50 mW/sr |
| 순방향 전압 V_F (I_F = 100 mA) | 1.30 V (max 1.5 V) |
| 최대 연속 순방향 전류 | 100 mA |
| 패키지 | 5 mm (T-1 3/4), 지름 5.1 mm |
| 스위칭 시간 t_r, t_f | 1 µs |

스위칭 시간 1 µs는 본 설계의 200 ms 상태 유지 시간 대비 5자리수 여유이므로 전혀 제약이 되지 않는다.

### 3.2 파장 정합 — 양호

950nm 필터와 LD274의 950nm 피크가 일치한다. 스펙트럼 폭 55nm를 감안해도 에너지가 대략 920~990nm에 몰려 있어 필터 통과에 문제가 없다.

### 3.3 반각 ±10° — 최대 리스크 요인

LD274는 TV 리모컨용으로 설계된 협각 부품이다. 방사 특성상 ±10°에서 50%로 떨어지고 약 15° 이후 급락한다.

거리별 50% 빔 지름:

| 거리 | 빔 지름 |
|---|---|
| 1 m | 0.35 m |
| 3 m | 1.06 m |
| 10 m | 3.5 m |

**문제의 본질은 거리가 아니라 조준이다.** 팟이 흩어져 있고 카메라가 한쪽에 있으면 팟마다 LED가 향해야 할 방향이 전부 다르다. LED를 모두 수직으로 세우면 카메라 바로 아래 팟만 인식된다.

벤치 테스트(카메라를 LED 정면에 배치)에서는 문제가 되지 않으나, **양산 부품 선정 시 반각을 1순위 기준으로 삼아야 한다.** 파장과 전류는 나중에 맞출 수 있지만 반각은 고칠 수 없다. 권장: ±20~25° 급 광각 부품.

### 3.4 펄스 구동은 채택하지 않음

서지 3A, 펄스 내량상 D=0.05에서 1A까지 가능하나, 방사강도 그래프상 1A에서 광량은 100mA 대비 약 7배에 그친다(전류는 10배). 효율이 떨어진다.

카메라는 노출 시간 동안 에너지를 적분하므로, 같은 평균 전류라면 DC 구동이 유리하다. 펄스가 유리한 경우는 카메라 노출 창에 동기하여 구동할 때뿐인데, 프리러닝 카메라에서는 동기를 잡을 수 없다.

**대안: 카메라 노출 시간을 늘린다.** 33ms 프레임에서 노출을 1ms → 20ms로 늘리면 LED 에너지를 20배 적분한다. 950nm 필터가 주변광을 이미 억제하고 있다.

---

## 4. 하드웨어 구성

### 4.1 구동 전류 및 저항 산출

벤치 1m 환경에서는 오히려 **과다 광량이 문제**가 된다. 포화된 블롭이 번지면(blooming) 인접 지점과 뭉쳐 격자 분리가 깨진다.

```
목표 전류: 6 mA
V_F ≈ 1.1 V   (순방향 특성 그래프의 저전류 영역 판독값)
V_OL ≈ 0.4 V  (STM32L0 오픈 드레인 Low 출력)

R = (3.3 - 1.1 - 0.4) / 0.006 ≈ 300 Ω  →  330 Ω (E24)
```

- 9개 합계: 약 54 mA
- 밝기 부족 시 220 Ω으로 하향 (합계 약 72 mA)

거리별 조도 추정 (8 mA 기준, I_e ≈ 4 mW/sr):

| 거리 | 조도 | 판단 |
|---|---|---|
| 1 m | 4 mW/m² | 20ms 노출로 충분히 포화 |
| 3 m | 0.44 mW/m² | 가능, 노출 조정 필요 |
| 10 m | 0.04 mW/m² | 외부 드라이버 필요 |

**결론: 벤치 범위에서는 MOSFET·ULN2803A 등 외부 드라이버가 불필요하다.**

### 4.2 GPIO 전류 한계

STM32L073 GPIO는 정규 스펙에서 핀당 ±8 mA, 비정규 V_OL/V_OH를 감수하면 ±15 mA까지 sink/source 가능하다. 6 mA 구동은 정규 스펙 내에 안전하게 들어간다.

전체 I/O 합산 전류(ΣI_VSS) 한계는 데이터시트 절대최대정격 표에서 확인할 것.

**카운트 방식의 부수적 이점:** 지점마다 카운트가 다르므로 9개가 동시에 켜져 있는 순간은 첫 번째 블링크뿐이고, 이후로는 켜진 개수가 점차 줄어든다. 평균 전류가 최대치보다 낮다.

### 4.3 전원 경로

LED 전류를 Nucleo의 3.3V 핀에서 뽑는 것은 무방하나(54mA 수준), 향후 전류를 크게 올릴 경우 주의가 필요하다.

- ST-LINK USB 급전 시 열거 전에는 100 mA 제한
- 온보드 LDO를 MCU와 공유

전류를 20mA/개 이상으로 올릴 경우 **5V 핀 또는 별도 전원에서 공급하고 GND만 공유**한다. 이때는 반드시 MOSFET 또는 ULN2803A를 경유해야 한다 — **PC0~PC5는 ADC 겸용 핀이라 5V 톨러런트가 아니다.**

### 4.4 LED 물리 배치

**3×3 배열, 피치 10 mm**

LD274 패키지 지름이 5.1 mm이므로 몸통 간 5 mm 간격이 확보된다.

1 m 거리, FHD, 6 mm 렌즈 기준 약 2 px/mm:

| 항목 | 픽셀 |
|---|---|
| LED 중심 간 거리 (10 mm) | 약 21 px |
| 렌즈 돔 블롭 (5 mm) | 약 10 px |
| 블롭 사이 어두운 골 | 약 11 px |

3 m로 물러나면 분리 7 px, 블롭 3.4 px로 제안서의 "3×3 픽셀 이상 투영" 조건을 겨우 만족한다. **벤치는 1~1.5 m 권장.**

### 4.5 회로 요약

```
VDD 3.3V ──┬── LED(애노드) ... LED(캐소드) ── 330Ω ── PC0 (오픈 드레인)
           ├── LED ── 330Ω ── PC1
           ├── ...
           └── LED ── 330Ω ── PC8

오픈 드레인: 핀 Low = 전류 싱크 = 점등 / 핀 High = Hi-Z = 소등
```

---

## 5. 프로토콜 설계 — 블링크 카운트

### 5.1 격자 지점 매핑

각 핀은 격자 한 지점(팟 1개)에 대응한다. 비트 자리가 아니라 **독립된 측정 지점**이다.

| 격자 위치 | 핀 | 지점 번호 |
|---|---|---|
| 상단 좌 | PC0 | G1 |
| 상단 중 | PC1 | G2 |
| 상단 우 | PC3 | G3 |
| 중단 좌 | PC4 | G4 |
| 중단 중 | PC5 | G5 |
| 중단 우 | PC6 | G6 |
| 하단 좌 | PC7 | G7 |
| 하단 중 | PC8 | G8 |
| 하단 우 | PC2 | G9 |

이전 개정에서 PC0에 부여했던 토글 클록 역할은 **폐지**한다. 카운트 방식에서는 사이클 사이의 소등 구간이 그 역할을 대신하며, 9개 핀 전부를 데이터 지점으로 쓸 수 있다.

> **구현 노트:** 최초 CubeMX 생성 프로젝트는 G3~G8을 PC3~PC8에 배정하며 PC2를 비워둔 채였다(8지점만 구성됨, [6.7절](#67-cubemx-생성-결과-검증-중-발견된-오차)). 기존 G1~G8 배선을 그대로 두고 남은 PC2를 G9로 채워 9지점을 완성했다 — 핀 번호 순서상 G9가 G2와 G3 사이에 위치하지만, 격자 물리 배치와 핀 번호는 애초에 무관하므로 기능·회로에는 영향이 없다.

### 5.2 사이클 구조

```
├──────────── 버스트 구간 6.0 s ────────────┤├── 소등 2.0 s ──┤
│                                            │                 │
G1 ▇▁▇▁▇▁                                    │                 │  (3회)
G2 ▇▁▇▁▇▁▇▁▇▁                                │                 │  (5회)
G3 ▇▁                                        │                 │  (1회)
...                                          │                 │
G9 ▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁▇▁            │                 │  (15회)
                                             │                 │
└──────────────── 1 사이클 = 8.0 s ─────────────────────────────┘
```

**모든 지점이 동시에 시작하고, 각자의 카운트만큼 깜박인 뒤 소등한다.** 시작 시각이 같으므로 디코더가 사이클 경계를 잡기 쉽다.

### 5.3 타이밍 파라미터

| 항목 | 값 | 30 fps 기준 |
|---|---|---|
| 틱(상태 유지) 주기 | 200 ms | 6 프레임 |
| 블링크 1회 (점등+소등) | 400 ms | 12 프레임 |
| 블링크 주파수 | 2.5 Hz | — |
| 최대 카운트 | 15회 | — |
| 버스트 구간 | 6.0 s (30 틱) | 180 프레임 |
| 소등 구간 | 2.0 s (10 틱) | 60 프레임 |
| **사이클 전체** | **8.0 s (40 틱)** | 240 프레임 |

### 5.4 왜 200 ms인가

**프레임 여유가 결정 요인이다.**

| 프레임레이트 | 상태당 프레임 수 | 판정 |
|---|---|---|
| 30 fps | 6 | 여유 |
| 15 fps (저조도 자동 강등) | 3 | 최소 허용 |
| 10 fps | 2 | 위험 |

상태당 최소 3프레임을 확보해야 경계 프레임 1개를 버려도 판정이 가능하다. 200 ms는 카메라가 15 fps로 떨어지더라도 살아남는 값이다.

나이퀴스트 한계(30 fps → 15 Hz)만 보면 훨씬 빠르게 가도 될 것 같지만, **한계 근처에서는 프레임 하나만 밀려도 카운트가 틀어진다.** 요구 갱신 주기가 시간당 1회이므로 속도를 버리고 여유를 사는 것이 옳다.

### 5.5 사이클 길이 8초의 의미

제안서 4.가의 "신호 송출 지속 시간 1회 구동당 8초"와 일치시켰다. 프로토타입 결과를 제안서 수치로 그대로 인용할 수 있다.

또한 6초 버스트 안의 최대 15회는 **2.5 Hz**이고, 제안서의 1~6 Hz 대역과 같은 스케일이다. 카운트 방식에서 주파수 변조 방식으로 넘어갈 때 타이밍 재설계가 필요 없다.

### 5.6 분해능

카운트 1~15의 15단계. 제안서 4.나의 "수분 0~70% 구간 5% 단위 14단계"와 정합한다.

카운트 0은 사용하지 않는다. 0은 "소등"과 구분되지 않아 **고장난 지점과 값이 0인 지점을 혼동**하게 되기 때문이다. 최소 1회는 반드시 깜박이게 하여, 한 번도 깜박이지 않은 지점은 이상으로 판정한다.

---

## 6. CubeMX 설정

### 6.1 Clock Configuration

CubeMX 신규 프로젝트의 기본값은 **MSI 2.097 MHz**다. 이 상태로 타이머 값을 넣으면 주기가 7.6배 느려지는데, **에러 없이 조용히 느려지기 때문에** 발견이 늦어진다. 반드시 확인할 것.

**변경 지점: System Clock Mux → HSI 16 선택 (한 곳)**

나머지는 기본값 `/1`이 그대로 맞으므로 손대지 않는다.

| 항목 | 설정값 |
|---|---|
| System Clock Mux | **HSI** |
| SYSCLK | 16 MHz |
| AHB Prescaler | /1 → HCLK 16 MHz |
| APB1 Prescaler | /1 → PCLK1 16 MHz |
| **APB1 timer clocks** | **16 MHz (배율 X 1)** |
| APB2 Prescaler | /1 |
| HSI RC 분주 (HSI16DIVEN) | /1 |
| USART2 Source Mux | PCLK1 |
| USART2CLK | 16 MHz |
| Flash Latency | 0 WS (자동) |
| Power Regulator | Range 1 (자동) |

**"Resolve Clock Issues" 버튼이나 HCLK 직접 입력은 피할 것.** CubeMX가 PLL 경로를 자동으로 잡아 불필요하게 PLL을 켜는 경우가 있다. Mux를 직접 클릭하는 편이 의도한 경로를 보장한다.

#### 타이머 2배 규칙

```
APB 분주 = 1  →  타이머 클록 = PCLK       (16 MHz)
APB 분주 ≠ 1  →  타이머 클록 = PCLK × 2   (자동 2배)
```

APB1을 `/1`로 고정하면 이 규칙을 생각할 필요가 없다. 클록 트리의 `X 1` 표시가 확인 지점이다.

#### MSI가 아닌 HSI16을 쓰는 이유

전력이 무관한 프로토타입이므로 판단 기준은 정확도뿐이다.

| 클록원 | 정확도 | UART 115200 오차 |
|---|---|---|
| MSI 2.097 MHz | ±3% 이상 | USARTDIV 18.2 → 18, 약 1.1% + 클록 오차 |
| **HSI16** | ±1% 수준 | USARTDIV 138.9 → 139, **-0.08%** |

UART는 누적 오차 2.5% 부근에서 깨진다. **로그가 이번 테스트의 검증 수단**이므로 여기서 여유를 확보해야 한다.

### 6.2 GPIO (PC0 ~ PC8)

| 항목 | 설정값 | 이유 |
|---|---|---|
| GPIO mode | Output Open Drain | 요구 사양 |
| Pull-up/Pull-down | No pull-up and no pull-down | 풀업은 외부 LED+저항이 담당. 내부 풀업 시 소등 상태에서도 누설 |
| Maximum output speed | Low | 저속 신호에 고속 슬루 불필요, EMI·소비전류 감소 |
| **GPIO output level** | **High** | OD에서 High = Hi-Z = 소등. Low로 두면 부팅 즉시 9개 전부 점등 |

**User Label:**

```
PC0 → G1    PC1 → G2    PC2 → G9
PC3 → G3    PC4 → G4    PC5 → G5
PC6 → G6    PC7 → G7    PC8 → G8
```

`GPIO output level`을 High로 두는 것이 가장 실수하기 쉬운 지점이다. 푸시풀 습관대로 Low를 넣으면 리셋 직후부터 LED가 켜진 채로 있게 된다.

### 6.3 PC0~PC8 선택 이유

- **단일 포트 연속 배치** → `BSRR` 한 번의 쓰기로 9개를 완전히 동시에 전환 가능
- 회피 대상 핀과 충돌 없음: PA13/PA14(SWD), PA2/PA3(ST-LINK VCP), PC13(B1 버튼), PC14/PC15(LSE), PA5(LD2)
- NUCLEO-64 모포 헤더 실제 핀맵은 보드 문서에서 확인할 것

### 6.4 타이머

| 항목 | 값 |
|---|---|
| 타이머 | TIM6 |
| Prescaler | 15999 |
| Counter Period (ARR) | 199 |
| 결과 주기 | **200 ms** |
| NVIC | TIM6 global interrupt 활성화 |

```
f_TIM6 = 16,000,000 Hz
카운터 클록 = 16,000,000 / (15999+1) = 1,000 Hz
주기 = (199+1) / 1,000 = 0.200 s
```

TIM6를 선택한 이유는 기본 타이머(Basic Timer)라 GPIO 대체 기능을 전혀 점유하지 않기 때문이다. **PC6~PC8은 TIM3 채널과 겹치므로 TIM3는 사용하지 않는다.**

### 6.5 UART

| 항목 | 값 |
|---|---|
| USART2 | Asynchronous, 115200 8N1 |
| 용도 | 사이클별 정답 카운트 로그 (ST-LINK VCP 경유) |

**이 로그가 핵심이다.** MCU가 사이클마다 "G1=3, G2=5, …"를 찍어주면 디코더 결과와 대조하여 지점별 에러율을 정량화할 수 있다. 이것이 없으면 디코더 오류인지 광학계 오류인지 구분할 수 없다.

### 6.6 (선택) MCO — 클록 실측용

MCO Source Mux를 SYSCLK에 두고 **PA8을 RCC_MCO로 활성화, 분주 `/16`** 으로 설정하면 PA8에 정확히 **1 MHz**가 나온다.

```
16 MHz / 16 = 1.000 MHz
```

`HAL_RCC_GetSysClockFreq()`는 레지스터 설정값을 계산한 것이라 **실제 발진 주파수를 검증하지 못한다.** MCO는 물리적 증거를 준다. PA8은 CN9의 D7에 나와 있고 PC0~PC8과 충돌하지 않는다. 브링업 1회 측정 후 꺼도 무방하다.

### 6.7 CubeMX 생성 결과 검증 중 발견된 오차

7장 펌웨어를 실제 코드에 적용하는 과정에서 `.ioc`가 생성한 `gpio.c`를 본 문서의 6.2절 스펙과 대조한 결과, 두 가지가 어긋나 있었다. 둘 다 `.ioc`와 `gpio.c`/`main.h`에서 함께 고쳐 CubeMX로 재생성해도 유지되도록 했다.

| 항목 | CubeMX 생성 결과 | 문제 | 조치 |
|---|---|---|---|
| GPIO output level 초기값 | `GPIO_PIN_RESET` (Low) | 오픈 드레인에서 Low = 점등이므로, **리셋 직후 9개 LED가 전부 켜진 채로 부팅**된다. 6.2절이 "가장 실수하기 쉬운 지점"으로 미리 경고한 바로 그 실수 | `GPIO_PIN_SET` (High)으로 수정 |
| 지점 수 | G1~G8 (PC0, PC1, PC3~PC8) 8개만 구성, **PC2 미사용** | 3×3=9지점 설계와 불일치. PC2가 비어 연속 배치가 깨지므로 7.2절이 전제하는 BSRR 단일 쓰기 방식도 그대로는 성립하지 않음 | PC2를 G9로 추가해 PC0~PC8 9핀 연속 배치 완성 (기존 G1~G8 배선은 유지 — 상세는 [5.1절 구현 노트](#51-격자-지점-매핑)) |

두 항목 모두 최초 CubeMX 프로젝트 생성 시점(설계 확정 이전)의 산출물이 그대로 남아 있던 것으로 보이며, 이번 구현 작업 중 발견했다.

---

## 7. 펌웨어

### 7.1 전체 코드

아래 코드는 [`Core/Src/main.c`](../Core/Src/main.c)에 CubeMX의 `USER CODE BEGIN/END` 구획을 따라 그대로 구현되어 있다 (재생성 시에도 보존됨). 실제 배치는 매크로·전역변수 → `PV`, `load_counts`/`drive`/`__io_putchar` → `0`, `HAL_TIM_PeriodElapsedCallback` → `4`, 초기 구동 호출 → `main()`의 `2` 구획이다. `app_start()`는 설명용 표기이며 실제로는 별도 함수로 분리하지 않고 `main()`의 해당 구획에 인라인되어 있다.

```c
#define LED_MASK      0x01FFu     /* PC0..PC8 */
#define N_POINTS      9
#define COUNT_MIN     1
#define COUNT_MAX     15
#define TICKS_BURST   30          /* 15회 × 2틱 = 6.0 s */
#define TICKS_IDLE    10          /* 2.0 s */
#define TICKS_CYCLE   (TICKS_BURST + TICKS_IDLE)

static uint16_t tick   = 0;
static uint16_t cycle  = 0;
static uint8_t  cnt[N_POINTS];    /* 지점별 목표 블링크 횟수 */

/* 결정적 회전 패턴: 지점마다 다른 값, 사이클마다 1씩 이동 */
static void load_counts(void)
{
    for (uint8_t k = 0; k < N_POINTS; k++) {
        cnt[k] = (uint8_t)(((cycle + k) % COUNT_MAX) + COUNT_MIN);
    }
}

static void drive(uint16_t bits)
{
    /* 오픈 드레인: 0 = 점등이므로 반전. 9핀 원자적 동시 전환 */
    GPIOC->BSRR = ((uint32_t)( bits & LED_MASK) << 16)
                | ((uint32_t)(~bits & LED_MASK));
}

/* printf -> USART2 (ST-LINK VCP). syscalls.c의 weak _write()가 문자마다 호출한다. */
int __io_putchar(int ch)
{
    HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance != TIM6) return;

    uint16_t bits = 0;

    /* 버스트 구간의 짝수 틱에서만 점등.
       지점 k의 j번째 블링크는 틱 2j(점등) / 2j+1(소등)을 점유 */
    if (tick < TICKS_BURST && (tick & 1u) == 0u) {
        for (uint8_t k = 0; k < N_POINTS; k++) {
            if (tick < (uint16_t)cnt[k] * 2u) {
                bits |= (uint16_t)(1u << k);
            }
        }
    }
    drive(bits);

    if (++tick >= TICKS_CYCLE) {
        tick = 0;
        cycle++;
        load_counts();

        printf("CYCLE=%u G:", cycle);
        for (uint8_t k = 0; k < N_POINTS; k++) printf(" %2u", cnt[k]);
        printf("\r\n");
    }
}

/* main()의 while 루프 진입 전 */
void app_start(void)
{
    load_counts();
    drive(0);                       /* 전 지점 소등 */
    HAL_TIM_Base_Start_IT(&htim6);
}
```

### 7.2 BSRR 직접 사용

`HAL_GPIO_WritePin()`을 9번 호출하면 핀마다 수십 ns씩 어긋나고 함수 호출 오버헤드가 붙는다. BSRR 직접 쓰기는 원자적 1사이클 연산이다.

블링크 카운트 방식에서는 지점 간 수십 ns 오차가 실질적 문제는 아니지만, 코드가 더 짧고 명확하므로 유지한다.

### 7.3 Mock 값은 난수가 아닌 결정적 회전

`cnt[k] = ((cycle + k) % 15) + 1`

| 사이클 | G1 | G2 | G3 | … | G9 |
|---|---|---|---|---|---|
| 1 | 1 | 2 | 3 | … | 9 |
| 2 | 2 | 3 | 4 | … | 10 |
| 3 | 3 | 4 | 5 | … | 11 |
| … | | | | | |
| 15 | 15 | 1 | 2 | … | 8 |

**이 패턴이 주는 것:**

- 한 사이클 안에서 9개 지점이 **전부 다른 값**을 가지므로, 디코더가 지점을 혼동하면 즉시 드러난다
- 사이클마다 값이 1씩 이동하므로 **특정 지점만 계속 틀리는지**(위치 의존 오류) 판별 가능
- 15 사이클 = **120초**면 모든 지점이 모든 카운트를 한 번씩 경험한다

난수 mock으로는 이 중 어느 것도 알 수 없다.

### 7.4 printf 리다이렉트

`syscalls.c`(CubeMX 생성)는 `_write()`가 문자 단위로 `__io_putchar()`를 호출하도록 이미 `weak` 참조를 걸어두고 있으나, 정작 `__io_putchar()` 본체는 어디에도 정의돼 있지 않았다. `main.c`에 `HAL_UART_Transmit(&huart2, ...)`으로 구현해 연결했다 (7.1절 코드 참조).

빌드는 newlib-nano(`--specs=nano.specs`)를 링크하므로 `_write()` 경로가 실제로 사용된다. `__io_putchar()`는 `weak extern`으로 선언돼 있어 정의가 없어도 링크 자체는 성공하지만, 그 상태에서 `printf()`를 호출하면 널 포인터를 호출하는 셈이라 하드폴트로 즉시 정지한다 — "링크되니 괜찮겠지"로 넘어가면 안 되는 지점이다.

### 7.5 빌드 검증

```
RAM:   2224 B / 20 KB   (10.86%)
FLASH: 17232 B / 192 KB (8.76%)
```

**툴체인 주의:** Homebrew의 `arm-none-eabi-gcc`는 newlib 헤더(`stdint.h` 등)가 빠져 있어 `stm32l0xx_hal_tim.c` 등 표준 헤더를 include하는 모든 파일에서 빌드가 실패한다. STM32CubeCLT에 포함된 `GNU-tools-for-STM32`(`/opt/ST/STM32CubeCLT_*/GNU-tools-for-STM32/bin`)를 PATH에 우선 배치해야 한다. `cmake/gcc-arm-none-eabi.cmake`는 `arm-none-eabi-gcc`를 PATH에서만 찾으므로 툴체인 파일 수정 없이 PATH 순서만으로 해결된다.

```
export PATH="/opt/ST/STM32CubeCLT_*/GNU-tools-for-STM32/bin:$PATH"
cmake --preset Debug
cmake --build build/Debug
```

---

## 8. 디코더 설계 지침

### 8.1 ROI 자동 검출

버스트 구간에서는 모든 지점이 최소 1회 점등하므로, **버스트 구간 프레임들의 최대값 투영(max projection)** 을 취하면 9개 블롭이 한 장에 모두 나타난다.

```
roi_map = max(frame[t] for t in burst_window)
→ 임계값 이진화 → 연결 요소 라벨링 → 9개 중심 좌표
```

수동 ROI 지정보다 안정적이고, 격자가 흔들려도 사이클마다 재계산할 수 있다.

### 8.2 사이클 경계 검출

소등 구간 2.0초(60프레임)는 어떤 블링크 간격(200ms, 6프레임)보다 압도적으로 길다. **전 ROI가 동시에 어두운 상태가 30프레임 이상 지속되면 사이클 경계**로 판정한다.

### 8.3 카운트 산출

지점마다 독립적으로 처리한다.

1. ROI 내 픽셀 밝기 평균 → 시계열
2. 임계값으로 이진화 (임계값은 해당 지점의 min/max 중간값)
3. **상승 에지(off→on) 개수**를 센다
4. 각 on/off 구간이 3프레임 미만이면 노이즈로 간주하고 병합

3번에서 하강 에지가 아닌 상승 에지를 세는 이유는, 버스트 종료 후 소등 상태가 이어지므로 마지막 하강 에지가 사이클 경계와 겹칠 수 있기 때문이다.

### 8.4 검증 지표

| 지표 | 산출 |
|---|---|
| 지점별 정확도 | (일치 사이클 수) / (전체 사이클 수), 9개 각각 |
| 카운트별 정확도 | 카운트 1~15별로 집계 — 큰 값에서 오류가 느는지 확인 |
| 오류 방향 | 과소 계수(블링크 놓침) vs 과다 계수(노이즈) 구분 |

**과소 계수가 많으면** 노출 부족 또는 임계값이 높음. **과다 계수가 많으면** blooming으로 인접 지점 간섭 또는 임계값이 낮음. 오류의 방향이 곧 조치 방향이다.

---

## 9. 카메라 설정 — 반드시 수동 고정할 항목

여기서 실패하는 경우가 많다.

| 항목 | 조치 | 이유 |
|---|---|---|
| 자동 노출 (AE) | **끄기** | 켜진 LED 개수가 사이클 내내 변하므로 노출이 계속 요동침 |
| 자동 게인 (AGC) | **끄기** | 동일 |
| 자동 화이트밸런스 | **끄기** | 950nm 단색이라 무의미하고 방해만 됨 |
| 프레임레이트 자동 조정 | **끄기** | 어두우면 자동으로 15fps로 떨어지는 카메라가 많음 |
| 노출 시간 | 20 ms 부근에서 시작 후 튜닝 | 블롭 중심만 포화되고 번지지 않는 지점 탐색 |

**AE는 카운트 방식에서 특히 치명적이다.** 버스트 초반에는 9개가 켜져 있다가 후반으로 갈수록 줄어드는데, AE가 이를 보상하려 게인을 올리면 남은 지점의 블롭이 커져 인접 지점을 침범한다. 카운트가 큰 지점일수록 후반부에 오류가 몰리는 형태로 나타난다.

노출 튜닝이 이번 벤치 테스트에서 가장 시간이 많이 소요될 지점이다.

---

## 10. 브링업 절차

1. **클록 검증** — 코드 생성 후 UART 로그가 **8초마다 1줄** 나오는지 확인 (MCO를 켰다면 PA8에서 1 MHz 실측)
2. **LED 1개(PC0)만 330Ω으로 연결** → 스마트폰 카메라로 점등 확인 (필터 없이)
3. **950nm 필터 장착** → 실제 카메라로 블롭 확인, 노출 튜닝
4. **9개 전부 연결** → 버스트 구간을 max projection 하여 **9개 블롭이 분리되는지** 확인
5. **1개 사이클 녹화** → 수동으로 프레임을 넘겨가며 G1의 블링크를 육안 계수, UART 로그와 대조
6. **디코더 자동 계수** → 15 사이클(120초) 이상 녹화 후 정확도 산출

4번에서 블롭이 뭉치면 피치를 넓히거나 카메라 거리를 조정한다. **이 단계가 통과되지 않으면 디코더를 아무리 수정해도 소용없다.**

5번을 건너뛰지 말 것. 육안 계수 1회가 디코더 디버깅 수 시간을 절약한다.

---

## 11. 제안서 수정 필요 항목

프로토타입 완료 후 문서 갱신 시 놓치기 쉬운 지점들.

| 위치 | 현재 기재 | 수정 방향 |
|---|---|---|
| 2. 다. 4단계 | 850nm 파장의 IR LED | 950nm |
| 3. 가. 광출력 발광부 | 고효율 850nm IR LED | 950nm |
| 4. 가. 신호 발광 소자 | 850nm 고출력 IR 광 LED | 950nm |
| 4. 나. 수집 카메라 사양 | 850nm 밴드패스 필터 일체형 | 950nm 필터, C 마운트 별도 장착 |
| 6. BOM 2번 | 850nm 파장 5mm 외형 | 950nm |
| 2. 다. 1단계 | Stop2 초저전력 슬립 모드 | **Stop2는 STM32L4 계열 용어.** L0는 Stop 모드 |

**Stop 모드 관련 보충:** STM32L0의 Stop 모드는 RTC 유지 시 수 µA대로, 제안서에 기재된 5µA보다 오히려 유리하다. 수치는 문제없으나 모드 명칭이 발주처 기술 검토에서 지적될 수 있다.

---

## 12. 본 프로토타입 이후 결정할 사항

### 12.1 카운트 → 주파수 변조 전환

제안서 본안은 1~6 Hz 주파수 변조 + FFT 방식이다. 본 프로토타입의 카운트 방식은 그 하위 단계이며, 전환 시 바뀌는 것은 다음과 같다.

| 항목 | 프로토타입 (카운트) | 제안서 본안 (주파수) |
|---|---|---|
| 송출 | 6초간 N회 블링크 | 8초간 f Hz 연속 점멸 |
| 판독 | 상승 에지 계수 | FFT / 제로크로싱 |
| 노이즈 내성 | 프레임 누락에 취약 | 주파수 영역이라 강함 |
| 필요 프레임레이트 | 30 fps로 충분 | 6 Hz 판독 시 최소 15 fps 이상 |

카운트 방식에서 확보한 ROI 검출·임계값 산출 코드는 그대로 재사용된다. 바뀌는 것은 시계열 처리 단계뿐이다.

### 12.2 LED 부품 교체 기준 (우선순위 순)

1. **반각** — ±20~25° 이상. 이것이 실제 설치 가능성을 좌우함
2. 파장 — 필터와 정합 (현 필터 유지 시 940~950nm)
3. 방사강도 — 목표 거리에서의 조도로 역산
4. 패키지 — 3×3 배치 시 피치 확보 가능한 크기

### 12.3 미결 항목

- **실제 목표 인식 거리** — 벤치 1m 검증 후 온실 실환경 거리 확정 필요. 3m 초과 시 외부 드라이버 및 광각 LED 필수
- **격자 지점 수 확장** — 9개는 MCU 핀 수 제약이 아니라 프로토타입 편의값. 제안서의 "화각 내 2,000개 이상"까지 가려면 지점당 1 MCU 구조가 되므로 본 프로토타입과 아키텍처가 다름
- **팟 배치와 카메라 위치 기하** — 조준 문제의 실제 발생 여부는 이 기하에 달려 있음
- **저전력 전환** — LPTIM1 + LSE 기반 구조. NUCLEO-L073RZ의 LSE 수정(X2) 실장 여부 확인 필요

---

## 부록: 핵심 수치 요약

```
LED           : LD274, 950nm, 반각 ±10°, V_F ≈ 1.1V @ 6mA
저항           : 330Ω (대안 220Ω)
구동 전류       : 6 mA/개, 최대 합계 54 mA
배치           : 3×3, 피치 10mm, 9개 격자 지점
거리           : 1 ~ 1.5 m

틱 주기        : 200 ms (30fps 기준 6프레임)
블링크 1회      : 400 ms (2.5 Hz)
카운트 범위     : 1 ~ 15 (0은 미사용)
버스트 구간     : 6.0 s (30 틱)
소등 구간       : 2.0 s (10 틱)
사이클          : 8.0 s (40 틱)
전 조합 커버리지 : 15 사이클 = 120 초

MCU           : STM32L073RZ
클록           : HSI16 → SYSCLK 16MHz, AHB /1, APB1 /1, 타이머 X1
TIM6          : PSC 15999 / ARR 199 → 200 ms
GPIO          : PC0~PC8, Open Drain, No pull, Low speed, Output level High
로그           : USART2 115200 8N1, 사이클당 1줄
```