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

📌 이 프로젝트는 2차 버전으로 이어집니다 → TheaterControl — 센바스 HMI로 만든 소극장 무대 제어 시스템. 조명 중심의 1차에서, 무대 장치 제어 전반(Machine/RD)으로 확장하고 통신·안전 모델을 다시 설계했습니다.


라즈베리파이 터치 HMI로 무엇을 만들었나

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

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

시스템 흐름은 단순합니다:

PLC 슬레이브 ── Modbus TCP ── 라즈베리파이 (GoingPanel 앱)
     ↓                              ↓
채널 상태 워드(상태 레지스터)   SENVAS HMI / SkiaSharp 렌더
보드 검출 워드(검출 레지스터)   → 1024×600 터치 디스플레이
     ↑                              ↓
채널 이벤트 워드 ← 사용자 터치 (상승/하강/멈춤/lamp toggle)
항목내용
타겟 디바이스SENVAS Touch (Raspberry Pi linux-arm64, 1024×600)
호스트 OSDebian 12 + 터치 런처 서비스
프레임워크.NET 8.0 + SENVAS HMI + 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();
}

통신 매니저 (~250줄)는 통신 전담입니다. 백그라운드 스레드가 채널 상태 워드와 보드 검출 워드를 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라운드

라즈베리파이 GPIO 부저는 터치 피드백용이라 간단해 보였습니다. 결과적으로 다섯 번을 시도했습니다.

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

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

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

public static class TouchBuzzer
{
    private const string BuzzerUrl = "http://localhost/api/touch-beep"; // 터치 런처 로컬 API
    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초 이상 갱신되지 않으면 끊김으로 판정합니다.

// 커스텀 Modbus 클래스 — 응답 성공 시마다 갱신
LastSuccessAt = DateTime.UtcNow;

// 통신 매니저 — 링크 상태 판정
public bool LinkOk =>
    TCP.IsOpen && (DateTime.UtcNow - TCP.LastSuccessAt).TotalSeconds < 2.0;

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

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


리소스·테마·레이아웃 트러블슈팅 6라운드

HMI 트러블슈팅은 주요 하이라이트 세 가지 외에도 개발 과정에서 예상치 못한 문제가 여럿 있었습니다.

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

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

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

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

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

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

현재 테마 싱글톤을 수정했는데 화면이 그대로였습니다. 소스를 직접 확인해보니 렌더 루프가 참조하는 건 그 싱글톤이 아니라 별도의 커스텀 테마 필드였습니다. 라이브러리에 정적 싱글톤이 여러 개 있으면 어느 것이 실제로 렌더링에 사용되는지 소스를 열어봐야 합니다.

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

외부에서 UI 라이브러리가 업데이트되면서 버튼 컴포넌트의 한 스타일 속성이 제거됐습니다. 로컬 ProjectReference의 양면성입니다. 즉시 변경 사항을 추적할 수 있다는 장점이 있지만, 외부 변경에 그대로 노출됩니다.

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

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

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

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

EMG 버튼 우측이 잘림

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


SENVAS Touch 배포 파이프라인

SENVAS Touch 배포 파이프라인에서 GoingPanel은 터치 런처 서비스 위에서 실행됩니다. 배포는 zip 패키지로 이루어집니다.

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

영구 설정은 앱 설치 경로와 분리된 OS 표준 경로에 저장됩니다. 앱을 재설치해도 이전 IP·채널 설정이 살아남습니다. 원격 관리 도구로 중지 → 제거 → 설치 → 시작 시퀀스를 코드에서 자동화할 수 있습니다.

GOING 로고


라즈베리파이 터치 HMI 개발 정리

라즈베리파이 터치 HMI 개발에서 트러블슈팅 6라운드, ON/OFF 시각화 4번 갈아엎기, 터치 부저 5회 시도를 돌아보면 공통된 패턴이 있습니다.

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

라이브러리의 “진실”은 소스에 있습니다. 현재 테마 싱글톤과 커스텀 테마 필드 혼동처럼, 문서에 없는 내부 동작은 직접 소스를 열어봐야 합니다. 정적 싱글톤이 여러 개일 때 어느 것이 실제로 쓰이는지 추측하면 늦습니다.

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

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

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

터치 HMI는 실제 화면에서 확인해야 하고, 하드웨어 자원은 이미 점유한 서비스의 API로 위임하며, 통신 상태는 마지막 성공 시각으로 판정해야 합니다.

📌 이 프로젝트의 2차 버전: TheaterControl — 센바스 HMI로 만든 소극장 무대 제어 시스템 — 조명 중심에서 무대 장치 제어 전반으로 확장한 후속작입니다.

문의