라즈베리파이로 만든 무대 조명 컨트롤 HMI — .NET 8.0 + SkiaSharp 실전 개발기

라즈베리파이로 만든 무대 조명 컨트롤 HMI — .NET 8.0 + SkiaSharp 실전 개발기

1024×600 메인 HMI 화면 — 미드나잇 테마 미드나잇(인디고) 테마, 머신 탭. 좌측 상단 GOING 로고, 우측 상단 비상 EMG 버튼. 각 카드에 상승/멈춤/하강 버튼이 배치되어 있다.


무엇을 만들었나

공연 무대에서 쓰이는 조명과 기계 장치를 제어하는 터치 패널 HMI다. 라즈베리파이에 7인치 1024×600 터치 디스플레이를 달고, .NET 8.0 + SkiaSharp 기반의 Going.UI 위에 UI를 올렸다. PLC와는 Modbus TCP로 통신한다.

무대 위에서 스위치 하나 잘못 누르면 세트가 오작동한다. 이 패널은 현장 스태프가 빠르고 직관적으로 조명을 켜고 기계를 올리고 내리는 도구다. 잘못 누른 걸 즉시 알 수 있어야 하고, 통신이 끊기면 바로 보여야 한다.

시스템 흐름은 단순하다:

PLC 슬레이브 ── Modbus TCP ── 라즈베리파이 (GoingPanel 앱)
     ↓                              ↓
채널 상태 워드 D100~D224      Going.UI / SkiaSharp 렌더
보드 검출 워드 D400~D417      → 1024×600 터치 디스플레이
     ↑                              ↓
채널 이벤트 워드 ← 사용자 터치 (상승/하강/멈춤/lamp toggle)
항목내용
타겟 디바이스Senvas-Touch (Raspberry Pi linux-arm64, 1024×600)
호스트 OSDebian 12 + LauncherTouch 서비스
프레임워크.NET 8.0 + Going.UI + SkiaSharp
통신Modbus TCP (커스텀 SimpleModbusTcp)
폴링 주기50ms, freshness threshold 2초
테마 프리셋5종 (Ocean / Midnight / Forest / Sunset / Mono)
아이콘 라이브러리11종 × 2(off/on) = 22장 PNG

소프트웨어 계층 설계

코드는 세 계층으로 나뉜다.

PageMain (~900줄)은 화면의 전부다. 탭을 동적으로 만들고, 탭 안에 카드를 빌드하고, 매 프레임 상태를 갱신한다. PLC에서 읽어온 보드 검출 워드가 바뀌면 카드 구성 자체를 재구성한다. 핵심은 시그니처 기반 재빌드다. 매 프레임마다 I2C 주소를 문자열로 직렬화해 지난 프레임과 비교하고, 변화가 감지될 때만 UI 요소를 재생성한다.

private string ComputeBoardSignature()
{
    var sb = new System.Text.StringBuilder();
    sb.Append("M:");
    foreach (var b in Main.DevMgr.MachineBoards)
        sb.Append(b.I2cAddress.ToString("X2")).Append(',');
    sb.Append("|R:");
    foreach (var b in Main.DevMgr.RdBoards)
        sb.Append(b.I2cAddress.ToString("X2")).Append(',');
    return sb.ToString();
}

DeviceManager (~250줄)는 통신 전담이다. 백그라운드 스레드가 채널 상태 125워드와 보드 검출 18워드를 50ms 주기로 폴링한다. 사용자 터치 이벤트는 이벤트 워드에 쓰고, 통신 상태는 LastSuccessAt 타임스탬프로 추적한다.

Theme 시스템 (~150줄)은 5종 컬러 팔레트를 정적 프로퍼티로 노출한다. Apply(themeName) 한 번 호출로 배경색, 버튼 색, 아이콘 강조색이 한꺼번에 바뀐다. UI 코드 어디서나 Theme.UpColor, Theme.IconAccent 를 즉시 참조한다.


ON/OFF 시각화 — 4번 갈아엎은 이야기

11종 무대 컨트롤 아이콘 × off/on 11종 무대 컨트롤 아이콘. OFF는 dim 톤, ON은 테마 강조색으로 동적 tint 처리.

이 프로젝트에서 가장 오래 시간을 쓴 건 아이콘의 ON/OFF 표현이었다. 코드가 문제가 아니라 사용자가 실제 화면을 보고 느끼는 게 문제였다.

v1: 색상만으로 구분 — 실패. ON은 테마 강조색, OFF는 어두운 회색. 어두운 배경 위에서 OFF 카드가 거의 안 보였다. “이거 꺼진 거야 아무것도 없는 거야?” 라는 피드백이 돌아왔다.

v2: ON에 halo 박스 — 반려. 아이콘 뒤에 반투명 컬러 박스를 깔았다. 시각적으로 구분은 됐지만 “보기 안 좋음”으로 반려.

v3: 카드 배경색 토글 — 반려. ON 카드만 컬러 배경, OFF는 기본 배경색. 결과는 “카드만 붕 떠 있어 보임”이었다. 카드마다 배경이 다르니 그리드가 들쭉날쭉해 보이는 게 맞는 말이었다.

v4 (최종): 통일 배경 + 아이콘 컬러만 변경. 모든 카드, 탭 배경을 같은 톤으로 고정했다. ON일 때 아이콘을 거의 흰색(violet-100 #E9E0FF)으로, OFF일 때 dim 톤(#5C5680)으로 tint한다. 텍스트도 ON은 Highlight, OFF는 흐린 회색.

핵심 구현은 SkiaSharp의 SrcIn 블렌드다. PNG의 알파(모양)만 살리고 색을 런타임에 교체한다.

public static void DrawTintedIcon(SKCanvas canvas, SKImage img, SKRect rect, SKColor tint)
{
    using var paint = new SKPaint
    {
        ColorFilter = SKColorFilter.CreateBlendMode(tint, SKBlendMode.SrcIn),
        IsAntialias = true,
    };
    var samp = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
    canvas.DrawImage(img, rect, samp, paint);
}

256×256 고해상도 PNG에 Linear+Mipmap 다운스케일을 쓰면 작은 카드 크기에서도 아이콘이 깔끔하게 보인다. 테마 5종에 대해 각각 ON/OFF 색을 정했고, 실제 디바이스 화면을 보면서 8번 톤을 조정했다. “색상은 한 번 정하고 끝나지 않는다”는 걸 이 과정에서 몸으로 배웠다.

사용자가 “카드만 붕 떠 있어 보임”이라고 하면 디자이너가 옳다. 시행착오 4번이라도 갈아엎는 게 답이다.


터치 부저 — GPIO 충돌의 5라운드

터치 피드백을 위한 부저는 간단해 보였다. 결과적으로 다섯 번을 시도했다.

#방법결과
1aplay로 WAV 파일 재생alsa-utils 없으면 무음
2SkiaSharp로 WAV 동적 생성 후 aplay사용자 거부
3GPIO 27 PWM 토글 (System.Device.Gpio)해당 핀에 부저 없음
4GPIO 18 (하드웨어 PWM)LauncherTouch가 이미 점유 — GPIO 충돌
5LauncherTouch HTTP API /api/touch-beep정상 작동

3번까지는 단순 실수였다. 진짜 교훈은 4번에서 나왔다. GPIO 핀을 두 프로세스가 동시에 열 수 없다. LauncherTouch 서비스가 이미 해당 핀을 잡고 있었고, 우리 앱이 같은 핀을 열려 하자 충돌이 일어났다.

해결은 위임이었다. LauncherTouch가 이미 그 핀을 제어하고 있으니, LauncherTouch한테 부저를 눌러달라고 요청하면 된다.

public static class TouchBuzzer
{
    private const string BuzzerUrl = "http://localhost:5001/api/touch-beep";
    private const int MinIntervalMs = 80;

    public static void Beep()
    {
        if (/* rate-limit check */) return;
        _ = Task.Run(async () =>
        {
            try { await _http.GetAsync(BuzzerUrl); }
            catch { }
        });
    }
}

MainWindow.OnMouseDown에서 이 메서드를 호출하면 모든 터치/클릭에 자동으로 비프음이 난다. fire-and-forget 패턴이라 UI 응답성에 영향이 없다.

하드웨어 자원을 다른 서비스가 점유하고 있을 땐 그 서비스의 API로 위임하라. “좋은 추상화”를 만들기보다 “이미 있는 추상화를 쓰는 것”이 항상 빠르다.


통신 끊김 감지 — Silent Disconnect 잡기

TCP 소켓이 살아 있어도 데이터가 흐르지 않는 경우가 있다. 네트워크 케이블이 빠지거나 PLC가 응답을 멈추면, TCP.IsOpentrue를 반환하는데 실제로는 아무 데이터도 오지 않는다. 이 상태를 UI가 “정상”으로 표시하면 현장에서 위험하다.

해결책은 단순하다. 마지막으로 성공한 시각을 추적하고, 이 시각이 2초 이상 갱신되지 않으면 끊김으로 판정한다.

// SimpleModbusTcp.cs — 응답 성공 시마다 갱신
LastSuccessAt = DateTime.UtcNow;

// DeviceManager.cs
public bool LinkOk =>
    TCP.IsOpen && (DateTime.UtcNow - TCP.LastSuccessAt).TotalSeconds < 2.0;

UI에서는 LinkOk가 false일 때 헤더에 빨간 경고 아이콘과 “통신끊김” 텍스트를 가로로 표시한다. 정상일 땐 아이콘을 숨겨 헤더를 깔끔하게 유지한다.

IsConnected 같은 불리언 하나로 통신 상태를 표현하지 말라. 마지막 성공 시각을 함께 추적하면 silent failure를 잡을 수 있다.


트러블슈팅 7라운드

주요 하이라이트 세 가지 외에도 개발 과정에서 여러 예상치 못한 문제들을 만났다.

임베드 PNG가 빨간 사각형으로만 나옴

아이콘 위치에 placeholder 빨간 사각형만 표시됐다. PNG 파일 존재 여부, Design.AddImage 실패 여부를 순차적으로 확인했는데 모두 이상이 없었다. 정답은 MSBuild 임베드 리소스 이름 불일치였다.

<!-- 잘못된 경우 — "icons\gear_off.png" 로 등록됨 -->
<LogicalName>%(RecursiveDir)%(Filename)%(Extension)</LogicalName>

<!-- 올바른 경우 — 파일명만 -->
<LogicalName>%(Filename)%(Extension)</LogicalName>

%(RecursiveDir)를 포함하면 폴더 prefix가 논리 이름에 붙어 런타임 lookup이 실패한다.

테마 변경해도 배경색이 안 바뀜

GoTheme.Current를 수정했는데 화면이 그대로였다. 소스를 직접 확인해보니 GoViewWindow.OnRenderFrame이 사용하는 건 GoTheme.Current가 아니라 Design.CustomTheme였다. 라이브러리에 정적 싱글톤이 여러 개 있으면 어느 것이 실제로 렌더링에 사용되는지 소스를 열어봐야 한다.

Going.UI 라이브러리 업데이트 후 빌드 에러 7개

외부에서 Going.UI가 업데이트되면서 GoButton.FillStyle 속성이 제거됐다. 로컬 ProjectReference의 양면성이다. 즉시 변경 사항을 추적할 수 있다는 장점이 있지만, 외부 변경에 그대로 노출된다.

재부팅 후 프로그램 레지스트리가 사라짐

MCP 연결이 끊겼다 복귀했을 때 디바이스 자체가 재부팅된 상태였다. LauncherTouch 프로그램 레지스트리가 휘발성 위치에 저장되어 있었던 것이 원인이었다. OS 표준 경로(Environment.SpecialFolder.LocalApplicationData)를 쓰면 uninstall/install 사이클에도 설정이 살아남는다.

보드 재스캔 결과가 메인 화면에 안 보임

설정 페이지에서 보드를 재스캔해도 메인 페이지로 나갔다 들어와야 변경이 보였다. 폴링 로직이 PageMain.OnUpdate에만 있었기 때문이다. 페이지 전환과 무관하게 살아 있어야 하는 폴링은 페이지 코드가 아닌 매니저에 두어야 한다.

EMG 버튼 우측이 잘림

비상 EMG 버튼의 둥근 우측 모서리가 잘려 평평하게 보였다. GoTableLayoutPanel 셀 안에서 Dock=Fill인 버튼이 셀 경계에서 clip되는 문제였다. Dock=0Bounds를 명시해도 layout pass에서 Fill이 재적용됐다. 결국 Margin = { Left=30, Right=30 }으로 양쪽 여백을 주는 게 더 안전한 방법이었다.


배포 파이프라인

GoingPanel은 Senvas-Touch의 LauncherTouch 서비스 위에서 실행된다. 배포는 zip 패키지로 이루어진다.

dotnet publish -c Release -r linux-arm64 --self-contained false
→ GoingPanel.zip 패키징
→ LauncherTouch API로 업로드 + 설치 + 시작

영구 설정은 앱 설치 경로와 분리된 OS 표준 경로에 저장된다. 앱을 재설치해도 이전 IP·채널 설정이 살아남는다. senvas-touch MCP를 쓰면 stop_program → uninstall → install → start 시퀀스를 코드에서 자동화할 수 있다.

GOING 로고


마무리 — 이 프로젝트에서 배운 것

트러블슈팅 7라운드, ON/OFF 시각화 4번 갈아엎기, 터치 부저 5회 시도를 돌아보면 공통된 패턴이 있다.

실제 화면을 보기 전엔 모른다. 색상 시스템은 코드에서 아무리 완벽해 보여도 실제 7인치 터치 디스플레이에서 빛을 받아야 진짜 결과를 알 수 있다. ON/OFF 톤을 8번 조정한 건 낭비가 아니라 과정이었다. 테마 변수로 설계해 두었기 때문에 5개 테마를 한 번에 바꿀 수 있었고, 그게 없었으면 수십 곳을 일일이 손봐야 했다.

라이브러리의 “진실”은 소스에 있다. GoTheme.CurrentDesign.CustomTheme 혼동처럼, 문서에 없는 내부 동작은 직접 소스를 열어봐야 한다. 정적 싱글톤이 여러 개일 때 어느 것이 실제로 쓰이는지 추측하면 늦다.

하드웨어 자원은 가진 쪽이 주인이다. GPIO 핀, 사운드 디바이스처럼 독점 자원은 이미 점유한 서비스의 API를 통해 간접적으로 쓰는 게 맞다. 직접 점유하려 다투면 충돌한다.

마지막 성공 시각은 항상 기록해라. IsConnected 하나로는 부족하다. 언제 마지막으로 정상 응답이 왔는지를 알아야 silent disconnect를 잡을 수 있다.

Going.UI와 Senvas-Touch는 GoingPanel이 직접 개발한 산업용 HMI 플랫폼이다. .NET 8.0 + SkiaSharp 위에 터치 패널 수준의 UI를 올리면서, 라즈베리파이 같은 임베디드 환경에서도 60fps 렌더링과 Modbus TCP 통신이 안정적으로 동작함을 이 프로젝트에서 확인했다.