구성을 바꾸자 증상이 돌아왔다Changing the Hardware Setup Brought the Symptom Back

3편 에서 초기 버전을 완료했다. 실제 공장 설치를 준비하며 컨트롤러와 패널 구성을 바꿨다.

In Part 3, I finished the initial build. While preparing the signboard for actual factory installation, I upgraded the controller board and panel chain.

좌우로 밀어서 표 전체 내용을 확인하세요
항목초기 버전교체 후
컨트롤러ESP32 NodeMCU-32S + 빵판 배선 + 외장 승압 모듈Adafruit Matrix Portal S3 (PID 5778) — 74AHCT245 레벨 시프터 내장, HUB75 16핀 직결
패널64×32 × 2장64×32 1/8 스캔 × 4장 (논리 256×32 / 물리 512×16 체인)
배선점퍼선 12가닥리본 케이블 직결 (접촉 저항·유격 제거)
Swipe horizontally to view the full table
ComponentInitial BuildUpgraded Setup
ControllerESP32 NodeMCU-32S + breadboard wiring + external boost moduleAdafruit Matrix Portal S3 (PID 5778) — built-in 74AHCT245 level shifter, direct 16-pin HUB75 header
Panels2× 64×32 panels4× 64×32 1/8-scan panels (logical 256×32 / physical 512×16 chain)
Wiring12 loose jumper wiresDirect ribbon cable connection (eliminating contact resistance and play)

레벨 시프터가 내장된 보드로 바꾸고 배선도 리본으로 정리했으니 신호는 더 깨끗해졌을 텐데, 배경의 푸르스름한 잔상이 다시 나타났다.

With a dedicated level-shifted controller board and clean ribbon cables, signal integrity should have been much better—yet the bluish background ghosting returned immediately.

Adafruit Matrix Portal S3 보드가 HUB75 패널 네 장에 리본 케이블로 연결된 모습
교체한 Adafruit Matrix Portal S3 와 HUB75 4장 체인 결착 상태.The upgraded Adafruit Matrix Portal S3 connected to the four-panel HUB75 chain.

이번에는 변수를 하나씩 되돌렸다Reverting Variables One by One

3편에서는 우연히 발견한 현상(가로 스크롤이 지나가면 사라진다)에서 바로 해결책으로 갔다. 이번에는 순서를 바꿨다. 원인 후보를 하나씩 원래 값으로 되돌려 재현되는지 확인했다.

In Part 3, I jumped straight from an accidental observation (that a full horizontal scroll cleared the tint) to a visual workaround. This time I reversed the process: I reverted candidate variables one at a time to see which exact change reproduced the bug.

좌우로 밀어서 표 전체 내용을 확인하세요
단계mxconfig.driverGPIO 드라이브 강도결과
1SHIFTREGCAP_1배경 정상 (Pitch Black)
2ICN2038SCAP_1푸른기 즉시 재발
3SHIFTREGCAP_3재발 없음
Swipe horizontally to view the full table
Stepmxconfig.driverGPIO Drive StrengthResult
1SHIFTREGCAP_1Background clean (Pitch Black)
2ICN2038SCAP_1Bluish tint reproduced immediately
3SHIFTREGCAP_3No tint reproduced
확정 원인mxconfig.driver = ICN2038S 설정 단 한 줄. GPIO 드라이브 강도도, 전원도, 배선도 아니었다.
Confirmed Root CauseA single configuration line: mxconfig.driver = ICN2038S. Neither GPIO drive strength, power supply, nor wiring was the culprit.

라이브러리 소스를 읽고 나서야 이해됐다Reading the Library Source Explained Why

ESP32-HUB75-MatrixPanel-DMA 라이브러리는 driver != SHIFTREG(0) 이면 begin() 에서 shiftDriver() 를 호출한다. 그런데 ICN2038S, FM6124, FM6126A 세 값이 전부 동일한 fm6124init() 으로 분기한다.

In the ESP32-HUB75-MatrixPanel-DMA library, setting driver != SHIFTREG(0) causes begin() to call shiftDriver(). Inside that function, ICN2038S, FM6124, and FM6126A all branch to the exact same fm6124init() routine.

이 초기화 루틴은 패널에 글로벌 전류 게인 레지스터(REG1)를 거의 최대값으로 기입한다. 그런데 내가 쓰는 패널의 실제 드라이버는 이 레지스터 포맷을 따르지 않는다. 기입된 값이 엉뚱하게 해석되면서 블랙 레벨 자체가 떠 버린 것이다.

That initialization sequence writes a near-maximum value into the panel's global current gain register (REG1). However, the actual driver silicon on my panels does not follow the FM6124 register format—so the written bits were misinterpreted, raising the black level across the panel.

// [원인] driver != SHIFTREG → shiftDriver() → fm6124init() → 전류 게인 레지스터 오기입
// mxconfig.driver = HUB75_I2S_CFG::ICN2038S;   // ❌ 패널 라벨이 ICN2038S여도 이 옵션과는 비호환

constexpr HUB75_I2S_CFG::shift_driver PANEL_DRIVER = HUB75_I2S_CFG::SHIFTREG;   // ✅ 확정 해결
mxconfig.driver           = PANEL_DRIVER;
mxconfig.i2sspeed         = HUB75_I2S_CFG::HZ_8M;   // 512px 체인 안전 속도 명시
mxconfig.min_refresh_rate = 60;
// [Root Cause] driver != SHIFTREG → shiftDriver() → fm6124init() → bad current gain register write
// mxconfig.driver = HUB75_I2S_CFG::ICN2038S;   // ❌ Incompatible even if the chip silkscreen says ICN2038S

constexpr HUB75_I2S_CFG::shift_driver PANEL_DRIVER = HUB75_I2S_CFG::SHIFTREG;   // ✅ Confirmed fix
mxconfig.driver           = PANEL_DRIVER;
mxconfig.i2sspeed         = HUB75_I2S_CFG::HZ_8M;   // Explicit safe clock for 512px chain
mxconfig.min_refresh_rate = 60;
패널 라벨을 믿으면 안 됐다패널에 붙은 칩 라벨이 ICN2038S 라고 해서 라이브러리의 ICN2038S 옵션을 고르면 되는 게 아니었다. 그 옵션이 실제로 어떤 초기화를 하는지는 소스를 직접 읽어야 알 수 있었다. 문서에는 "드라이버 칩에 맞춰 설정하라"고만 적혀 있다.
Don't Trust the Chip Label BlindlyJust because the IC silkscreen on the panel says ICN2038S does not mean you should select ICN2038S in the library config. The only way to know what that option actually transmitted on the wire was to read the library source code. The documentation simply says "set according to your driver chip."

왜 하필 파란색만 뜨는가Why Only Blue Glowed

블랙 레벨이 떴다면 R, G, B가 고르게 떠야 할 것 같은데 유독 푸르스름했다. HUB75 리본 케이블의 핀 배치를 보면 이유가 보인다.

If the black level was elevated, you would expect R, G, and B to glow equally—yet the tint was distinctly blue. Looking at the 16-pin HUB75 ribbon cable pinout explains why:

 1 R1  |  2 G1
 3 B1  |  4 GND    ← B1이 GND 바로 옆
 5 R2  |  6 G2
 7 B2  |  8 E      ← B2가 E 바로 옆 (1/8 스캔 패널은 내부 GND)
 9 A   | 10 B
11 C   | 12 D      ← 1/8 스캔 패널은 내부 GND
13 CLK | 14 LAT
15 OE  | 16 GND
 1 R1  |  2 G1
 3 B1  |  4 GND    ← B1 sits directly adjacent to GND
 5 R2  |  6 G2
 7 B2  |  8 E      ← B2 sits adjacent to E (tied to internal GND on 1/8-scan panels)
 9 A   | 10 B
11 C   | 12 D      ← Tied to internal GND on 1/8-scan panels
13 CLK | 14 LAT
15 OE  | 16 GND

Blue 두 라인(B1=3번, B2=7번)만 GND 계열 핀과 물리적으로 인접해 있다. 따라서 블랙 레벨이 조금만 떠도 R/G보다 B가 먼저 문턱값을 넘어 미세하게 점등되고, 결과적으로 "푸르스름한 배경"으로 보인다.

Only the two Blue lines (B1 on pin 3, B2 on pin 7) are physically adjacent to ground-referenced pins. Combined with Blue LEDs' lower forward voltage threshold relative to Red/Green driver biases when the current gain register is misconfigured, a slight shift in black level pushes the Blue channels across their conduction threshold first.

함께 정정한 오진 두 건Two More Misdiagnoses Corrected Along the Way

3편의 해결책은 무엇이었나"가로 스위핑 강제 방전"은 원인을 제거한 것이 아니라 증상을 덮은 우회책이었다. 화면이 한 번 훑고 지나가면서 프레임버퍼를 전부 덮어썼기 때문에 정상으로 보였을 뿐이다. 설정 한 줄이 원인이었다는 것은 변수를 하나씩 되돌리는 교차 검증을 하고 나서야 확정할 수 있었다.
What Was Part 3's Fix Really Doing?The "horizontal purge sweep" did not remove the root cause—it was a workaround that masked the symptom. Sweeping across the screen overwrote the entire DMA framebuffer once after initialization, making the display look clean. Only by reverting variables one by one did I prove that a single driver enum line was the real cause.

그리고 화면이 굳었다And Then the Display Froze Solid

리팩터링을 하고 나니 새 증상이 생겼다. 와이파이가 연결된 직후 화면이 멈춘 채 굳었다. 증상은 하나였지만 원인은 두 개가 겹쳐 있었다.

After refactoring the initialization code, a new bug appeared: right after Wi-Fi connected, the display froze completely. It looked like a single symptom, but two separate causes were stacked on top of each other.

원인 A — 스캔이 렌더링 루프를 잡고 있었다Cause A — Wi-Fi Scanning Blocked the Rendering Loop

WiFiMulti::run() 은 전 채널을 스캔하는 블로킹 호출이라 수 초간 CPU를 점유한다. 이걸 loop()(코어 1)에서 불렀기 때문에 스캔이 도는 동안 I2S 렌더링이 통째로 정지했다. 신호가 약할수록 스캔이 길어져 더 오래 멈췄다.

WiFiMulti::run() performs a blocking scan across all Wi-Fi channels, holding the CPU for several seconds. Because it was called inside loop() on Core 1, I2S frame updates stalled whenever a scan ran. Weaker signal conditions made scans take even longer.

3편에서 HTTP 수신만 코어 0으로 뺐는데, 스캔과 연결도 블로킹이라는 걸 놓쳤다. 모든 블로킹 네트워크 작업을 코어 0 전담 NetTask 로 격리했다. loop() 는 volatile 상태값만 읽고 절대 블로킹되지 않는다.

In Part 3, I had moved only the HTTP GET request to Core 0, overlooking that Wi-Fi scanning and association are also blocking calls. I moved all network operations into a dedicated Core 0 NetTask so loop() only reads volatile flags and never blocks.

원인 B — 리팩터링이 드러낸 순서 의존성Cause B — A Hidden Ordering Dependency Exposed by Refactoring

// [BEFORE] 스크롤 좌표 갱신이 else 분기 안에만 있었다
if (!isOfflineMode) {
    drawOnlineScreen();        // ← 내부에서 getLocalTime() 실패 시 drawOfflineScreen()으로 폴백
} else {
    if (now - lastScrollTime >= scrollSpeedMs) { offlineScrollX--; ... }   // ← 여기에만 존재
    drawOfflineScreen();
}
// [BEFORE] Scroll coordinate update lived only inside the else branch
if (!isOfflineMode) {
    drawOnlineScreen();        // ← Falls back to drawOfflineScreen() internally if getLocalTime() fails
} else {
    if (now - lastScrollTime >= scrollSpeedMs) { offlineScrollX--; ... }   // ← Only updated here
    drawOfflineScreen();
}

configTime() 은 비동기라 호출 직후에는 시계가 아직 무효다. NetTask 가 연결에 성공해 isOfflineMode = false 가 되면 drawOnlineScreen() 이 호출되지만, 시계가 없어 내부에서 오프라인 화면으로 폴백한다.

configTime() is asynchronous, so right after it is called, system time is not valid yet. Once NetTask connected and set isOfflineMode = false, loop() called drawOnlineScreen(), which saw invalid time and fell back to drawOfflineScreen() internally.

이때 offlineScrollX 는 else 분기에 있어 절대 갱신되지 않으므로 같은 프레임이 계속 그려진다 = 정지. UDP 123이 막혀 있거나 전파가 약해 SNTP가 끝내 동기화되지 않으면 영구 정지한다.

Because offlineScrollX was only decremented inside the else branch, the fallback path redrew the exact same scroll offset every frame—looking completely frozen. If UDP port 123 was blocked or weak signal prevented SNTP sync, the display stayed frozen forever.

전형적인 리팩터링 회귀 버그블로킹 NTP 대기를 제거하자, 그 대기에 암묵적으로 의존하던 렌더링 순서 문제가 표면화됐다. 예전 코드에서는 setup() 이 시계가 맞을 때까지 기다린 뒤 루프에 들어갔기 때문에 이 경로가 아예 발생하지 않았다.
A Classic Refactoring RegressionRemoving the blocking NTP wait in setup() exposed a rendering order dependency that had been hidden by that wait. In the older code, setup() blocked until the clock was valid before ever entering loop(), so the fallback path was never reached.

같이 잡은 것들Additional Fixes Along the Way

좌우로 밀어서 표 전체 내용을 확인하세요
버그영향조치
wifiMulti.addAP() 를 매 사이클 반복 호출내부 AP 리스트 무한 증식 → 힙 누수 + 스캔 시간 증가부팅 시 1회만 등록
WL_CONNECT_FAILED 1회로 인증 실패 확정 (3편의 해결책)전파가 약해도 이 코드가 뜨므로, 한 번 삐끗하면 영영 오프라인 고정3회 연속 기준으로 완화, 영구 잠금 제거
WIFI_PS_MIN_MODEM (3편의 판단)약전계에서 비콘 놓침 → 접속 직후 끊김WIFI_PS_NONE
WIFI_POWER_17dBm최대 출력이 아니었음 (ESP32-S3 최대는 19.5dBm)WIFI_POWER_19_5dBm, 연결 성공 시마다 재적용
재연결마다 날씨 API 강제 즉시 호출링크가 불안정하면 기상청 호출 한도 소진weatherIsStale() 판정 — 연결 유지가 최우선
setup() 의 블로킹 연결 + NTP 대기부팅 후 최대 20초 검은 화면전부 제거, 즉시 오프라인 화면부터 시작
Swipe horizontally to view the full table
BugImpactFix
Calling wifiMulti.addAP() on every reconnect cycleUnbounded internal AP list growth → heap leak + longer scan timesRegister APs once at boot
Locking offline after a single WL_CONNECT_FAILED (Part 3 decision)Weak RF also triggers this code, so one transient drop locked the sign offline until rebootRequire 3 consecutive failures and remove permanent lock
WIFI_PS_MIN_MODEM (Part 3 decision)Missed beacons in weak RF → disconnects right after associationReverted to WIFI_PS_NONE
WIFI_POWER_17dBmNot actually max TX power on ESP32-S3 (max is 19.5 dBm)Set WIFI_POWER_19_5dBm and re-apply after each connection
Forcing immediate weather API fetch on every reconnectFlapping Wi-Fi links rapidly exhaust daily KMA API quotaGate fetches behind weatherIsStale()
Blocking Wi-Fi + NTP wait inside setup()Up to 20 seconds of blank screen after power-onRemoved blocking waits; start offline marquee immediately at boot

3편에서 "해결"이라고 적었던 판단 중 세 개가 여기서 뒤집혔다. 모뎀 슬립, 인증 실패 영구 고정, 그리고 잔상 퍼지. 구성이 바뀌자 당시의 판단이 오히려 발목을 잡았다.

Three of the four decisions I called "solutions" in Part 3 were reversed here: modem sleep, permanent offline lock on auth failure, and the horizontal purge sweep. Once the hardware setup changed, those earlier workarounds became liabilities.

현장에 걸기 전에 — 전파를 화면으로 보기Before Mounting on Site — Visualizing RF Signal on the Panel

공장 현장은 금속 케이스 내부 설치가 예정돼 있어 RF 차폐가 우려됐다. 그런데 설치 환경에서는 PC를 붙이기 어렵고, PSU 5V가 인가된 상태에서 USB를 꽂으면 역전류 위험이 있어 시리얼 모니터에 의존할 수 없었다.

At the factory site, the signboard was scheduled to be mounted inside a metal enclosure, raising concerns about RF shielding. Bringing a laptop up to the mounting location was impractical, and plugging USB into the board while the external 5V PSU was live risked back-feeding current, ruling out the Serial Monitor.

그래서 전파 세기를 LED 화면 자체에 표시하도록 만들었다.

So I built an on-screen RSSI diagnostic tool directly on the LED matrix.

좌우로 밀어서 표 전체 내용을 확인하세요
기능동작
연결 토스트 RSSI 표기WiFi OK -58dBm(초록) / RSSI WARN -78dBm(주황) / PASSWORD ERR!(빨강). 문자열 길이 기반 자동 중앙정렬
RSSI 워치독온라인 상태에서 −75dBm 미만이면 10분 주기로 경고 토스트
UP 버튼 (온보드 GPIO6)전체화면 RSSI 모니터 토글 — 큰 dBm 수치 + GOOD/FAIR/WEAK 등급 + 게이지 바(−95~−35dBm)
DOWN 버튼 (온보드 GPIO7)패널 하드 리셋 — 표시가 깨졌을 때 전원 재투입 없이 DMA 재초기화
Swipe horizontally to view the full table
FeatureBehavior
Connection Toast RSSIWiFi OK -58dBm (green) / RSSI WARN -78dBm (orange) / PASSWORD ERR! (red), auto-centered by string length
RSSI WatchdogDisplays a warning toast every 10 minutes if online signal drops below −75 dBm
UP Button (onboard GPIO6)Toggles fullscreen RSSI monitor — large dBm readout + GOOD/FAIR/WEAK badge + bar gauge (−95 to −35 dBm)
DOWN Button (onboard GPIO7)Panel hard reset — re-initializes I2S DMA without cycling power if noise corrupts the display

버튼 극성은 부팅 시 실측해 자동 판별하므로 보드 리비전 차이를 코드 수정 없이 흡수한다.

Button active polarity is sampled automatically at boot, absorbing board revision differences without code changes.

실측해 보니 자세가 전부였다Field Measurements Showed Orientation Was Everything

LED 전광판 전체화면에 dBm 수치와 등급, 게이지 바가 표시된 RSSI 모니터 화면
UP 버튼으로 띄운 전체화면 RSSI 모니터. 설치 위치를 바꿔 가며 이 화면만 보고 판단했다.Fullscreen RSSI monitor toggled via the UP button. I used this screen alone to test mounting orientations.
좌우로 밀어서 표 전체 내용을 확인하세요
환경배치RSSI등급
실내 (배면 목재벽)기본 자세−66 ~ −59FAIR~GOOD
실내LED 패널면이 AP를 향함−58 ~ −56GOOD
실내모듈 안테나부가 AP를 향함−49GOOD
옥외 (대리석 벽 밀착)안테나가 대리석을 향함순간 −89 → −78 ~ −76WEAK
옥외 (대리석 벽 밀착)안테나가 패널 쪽을 향함−74 ~ −68FAIR
옥외벽에서 떼어냄−66 ~ −61GOOD
Swipe horizontally to view the full table
EnvironmentOrientation / PlacementRSSI (dBm)Rating
Indoors (wood backing wall)Default posture−66 to −59FAIR–GOOD
IndoorsLED panel face pointing toward AP−58 to −56GOOD
IndoorsModule PCB antenna pointing toward AP−49GOOD
Outdoors (flush against marble wall)Antenna facing marble wallDip to −89 → −78 to −76WEAK
Outdoors (flush against marble wall)Antenna facing LED panel backplane−74 to −68FAIR
OutdoorsSpaced away from wall−66 to −61GOOD
여기서 나온 결론자세만 바꿔도 최대 17dB 차이가 났다. LED 패널 PCB의 통 구리 그라운드 플레인 자체가 이미 상당한 차폐체라는 실측 증거다. "전면이 플라스틱이니 안테나를 앞으로 향하면 된다"는 통념은 성립하지 않았다. 벽에서 떼기만 해도 17dB 회복되는 것은 재질 감쇠보다 근접장 디튜닝이 지배적이라는 뜻이다. 2.4GHz에서 λ/10 ≈ 12mm 이내에 유전체나 금속이 닿으면 안테나 임피던스가 틀어져 단순 감쇠보다 훨씬 크게 손해를 본다.
What the Measurements ProvedChanging orientation alone swung reception by up to 17 dB. That confirmed the solid copper ground plane inside the LED panel PCB acts as a strong RF shield—disproving the assumption that "the front is plastic, so facing the panel toward the AP is fine." Recovering 17 dB simply by spacing the board away from the marble wall also showed that near-field antenna detuning dominated over material attenuation. At 2.4 GHz, placing dielectric or metal within λ/10 (≈12 mm) detunes the PCB antenna impedance, causing far more loss than free-space path loss.
안전장치NetTask 도입 이후에는 와이파이가 끊겨도 화면이 멈추지 않는다. 오프라인 마키가 계속 구동되고 15초 주기로 백그라운드 재연결을 시도하므로, 전파가 나빠도 전광판 본연의 안전수칙 표시 기능은 정상 동작한다. 날씨만 갱신이 늦어질 뿐이다.
Fail-Safe OperationWith NetTask isolated on Core 0, losing Wi-Fi never stalls the display. The offline safety marquee keeps scrolling while background reconnects run every 15 seconds—so even in poor RF conditions, the signboard's primary safety-notice function continues uninterrupted.

코드 정리Code Cleanup

이 시리즈에서 남은 것Takeaways from the Four-Part Series

네 편에 걸쳐 쓴 것을 한 줄씩 줄이면 이렇게 된다.

Summarizing the four parts into one line each:

이 유형의 실수(라이브러리 옵션이 실제로 무엇을 하는지 확인하지 않은 것, 핀 매핑을 가정으로 둔 것, 증상만 덮는 우회책)는 표로 정리해 프로젝트 문서에 남겼다. 다음에 같은 자리에서 시간을 쓰지 않기 위해서다.

I documented these failure patterns—assuming what a library enum does without reading the source, assuming pin mappings without checking the schematic, and mistaking visual workarounds for root-cause fixes—in the project notes so I don't pay the same tuition twice.