C++ 17로 윈도우에서 프로그램을 만들면서 폴더와 파일을 구분해야하는 경우가 생겨 아래 코드를 찾게되었다.
찾은 코드는 테스트 시 작동에는 문제가 없었지만 문자열을 LPCWSTR (WCHAR *)로 받아야 한다는 사소한 단점이 있었다. 혹시 String 클래스를 사용해 입력받은 경로가 폴더인지 파일인지 구분할 수 있는 방법이 있는지도 찾아봤지만, 검색을 해봐도 찾지 못했다..
int isFolder(LPCWSTR path) {
/*
args:
LPCWSTR fileName : 파일의 full path
return:
int code : 폴더는 1, 파일은 0, 에러는 -1
summary:
폴더인지 파일인지 구분
*/
WIN32_FIND_DATA wInfo;
HANDLE hInfo = ::FindFirstFile(path, &wInfo);
::FindClose(hInfo);
if (hInfo != INVALID_HANDLE_VALUE)
{
if (wInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
{
return 1;
}
return 0;
}
return -1;
}
만약 PC와 모니터가 2대씩 있을 때 각 PC를 둘 다 듀얼 모니터로 사용하고 싶다면 어떻게 하는게 좋을까? 가장 쉽게 문제를 해결하려면 매번 사용하는 PC의 HDMI 케이블을 바꿔서 연결하면 된다!
만약 그것도 귀찮다면 KVM 스위치를 구매해 연결해두고 필요한 PC의 버튼이나 단축키를 눌러서 사용하면 된다. (망 분리 환경이라면 보통 Aten과 같은 회사의 보안 인증이 된 제품을 지급해준다. 하지만 개인이 구매하기 어렵고 비싸다..!) 하지만 PC를 사용하다보면 양쪽 PC를 동시에 활용해야하는 경우가 꽤 많았고 개인적으로 KVM에서 듀얼 모니터 설정이 더 어려웠던 것 같다.
차라리 아래처럼 케이블을 전부 연결해두고 모니터 버튼으로 필요할 때마다 입력소스를 바꿔주는게 더 편하다.
많이 편해졌지만 여전히 몸을 움직여 버튼을 눌러야 하고 만약 PC 1을 듀얼로 사용하던 중에 PC 2를 듀얼로 사용하려면 버튼을 두번 눌러야 한다.
여러 대의 PC를 하나의 인터페이스로 다루는 KVM과 사용자의 편의를 위한 듀얼 모니터를 최소의 움직임으로 사용할 순 없는 걸까?
프로그램을 만들어보기로 했다.
MCCS, DDC/CI, VCP Code
MCCS (Monitor Control Command Set)는 모니터 제어 명령 세트로 VESA (Video Electronics Standards Association)에서 개발한 컴퓨터 디스플레이 표준이다. 보통 PC나 셋탑박스 같은 장치에서 모니터를 제어하기 위한 바이너리 프로토콜로 사용되지만, 해당 글에서는 프로그램 제작을 위해 사용하려고 한다.
MCCS를 활용하면 DDC/CI (Display Data Channel/ Command Interface)와 VCP (Virtual Control Panel) 코드를 통해 모니터에 명령을 보낼 수 있다.
VCP 코드는 가상 제어 패널의 약어로 아래 이미지와 같은 표준 명령어 타입을 따른다.
VCP 코드
정해진 값을 약속된 프로토콜로 모니터에 명령을 전달하면 모니터가 해당 동작을 수행한다. 예를 들어 VCP 코드로 0xD6을 넣어주면 모니터의 전원이 On/Off되고, 0x60을 전달하면 모니터의 입력 소스가 변경된다.
현재 만들어지는 모니터는 대부분(거의 전부) 해당 표준을 따르기 때문에 코드만 잘 전달하면 정말 편할 것 같다..! 근데 모니터에 코드를 어떻게 전달하라는 걸까?
여러 방식이 있겠지만 난 보통 윈도우를 많이 사용하기 때문에 exe 프로그램을 제작하기로 했다. Windows API를 통해 모니터로 VCP 코드를 보내보자.
모니터 제어 API
일반적으로 모니터와 관련된 API를 검색하면 모니터의 핸들을 얻어오거나 정보를 얻어오기 위해 MONITORINFOEXA와 같은 구조체나 GetMonitorInfo()와 같은 함수를 사용한다. 하지만 제어를 위해서는 DDC/CI 프로토콜에 맞춰서 모니터로 데이터를 보내주어야 한다.
MCCS 표준을 따르면 모니터 버튼으로 할 수 있는 동작 대부분을 프로그램으로 수행할 수 있지만 지금은 모니터 입력 소스만 정확하게 변경하면 된다.
위에서 VCP 코드를 DDC/CI 프로토콜에 따라서 모니터로 보내주기만 하면 기능이 수행된다고 했었다. 그런데 MSDN을 보면 API들은 내부적으로 DDC/CI를 사용하여 모니터에 명령을 보내준다고 되어 있기 때문에, VCP 코드만 제대로 맞춰서 전달해주면 될 것 같다.
이제 VCP 코드를 내가 원하는 모니터로 전달해주는 방법을 찾아야된다.
High-Level/ Low-Level Monitor 구성 함수
위 MSDN 링크에 들어가보면 High-Level과 Low-Level로 나뉘어진 함수들을 볼 수 있는데, 이 중 나에게 필요한 내용은 VCP 코드를 다룰 수 있는 Low-Level 함수들이다.
MSDN의 Low-level Monitor 함수 사용법
대충 윈도우에서 감지한 모니터들을 알려주는 Enum 함수와 VCP 관련된 함수들을 알려주는데, 8번의 SetVCPFeature 함수가 눈에 띄였다.
SetVCPFeature 함수는 위와 같은 형태로 되어 있다. Enum 함수 등으로 얻어온 모니터의 핸들을 구해서 넣고 원하는 VCP Code 값을 넣으면 될거 같다. 이제 변경을 원하는 모니터와 원하는 VCP 기능을 전달하는 방법은 알았다.
그런데 우리의 상황처럼 1개의 모니터에 연결된 케이블이 여러 개라면 원하는 케이블은 어떻게 설정해야 되는 걸까?
이 방법을 알기 위해 엄청난 검색을 했는데.. 방법은 의외로 간단했다. 3번째 인자인 dwNewValue에 해당 케이블의 값을 넣어주면 된다! 그렇다면 그 값은 어떻게 알 수 있을까??
다시 엄청난 삽질이 시작됐다.
GetVCPFeatureAndVCPFeatureReply() 함수
온갖 키워드와 방법으로 검색해 알게된 방법은 GetVCPFeatureAndVCPFeatureReply() 함수를 사용하는 것 이었다. 그런데 위의 MSDN 이미지의 7번을 자세히 보면 이미 설명이 잘 되어 있다. MS에선 처음부터 전부 다 알려주었지만 내가 이해를 못했을 뿐이다. (멍청하면 손발이 고생한다..)
함수를 살펴보면 먼저 SetVCPFeature 함수와 비슷하게 모니터의 핸들과 VCP 코드를 요구한다. 그리고 3, 4번째 인자를 통해 Current Value 값과 Maximum Value 값을 얻어 낼 수 있었다. (cf. 일반적으로 return을 통해 함수의 결과를 받지만 return으로는 하나의 값만 반환받을 수 있기 때문에 포인터 형태로 비어있는 변수를 전달해주면 알아서 채워준다.)
위의 함수를 잘 사용하면 모니터에 연결되어 있는 케이블들의 Value 값을 구할 수 있다. (HDMI, DP, VGA등에 따라 값이 다르다.)
이제 SetVCPFeature() 함수를 사용하는데 필요한 모든 데이터를 구했다.
하지만 아직 마지막 한 가지 문제가 남아있다. 모니터의 제조사나 모델마다 해당 Value의 값이 다르다는 것이다ㅋㅋ (예를 들면 A사의 A1 모니터는 HDMI의 Value가 10이고, B사의 B1모니터는 101이다. 심지어 같은 제조사의 다른 제품일 경우도 값이 다른 경우가 있었다.)
위의 API들을 잘 조합하면 자동으로 구할 수 있을 것 같았지만, 더 삽질하면 주말동안 끝내지 못할 것 같아 모니터들의 값을 알아낸 뒤 그냥 하드코딩했다.
개발 환경은 Visual Studio Community 2022이고 프로젝트는 아래 이미지처럼 구성했다.
jsoncpp.cpp는 c++에서 json 파싱을 위해 나중에 추가한 라이브러리 코드이고 test.cpp는 테스트 함수를 작성해둔 파일이라 생략했다. 그리고 사실 EnumDisplayMonitors 함수는 MonitorEnumProc라는 콜백 함수를 내부에서 호출하기 때문에 SetVCPFeature 함수를 사용하기 위해서 콜백 함수들을 작성해야 하는데, 해당 내용들은 나중에 시간되면 추가로 작성해보겠다.
코드는 돌아가게만 만들어 두었기 때문에 정리도 잘 안되어 있지만 이것도 나중에 정리하기로 하고 일단 올려보겠다.
function.h 코드
#pragma once
#pragma warning(disable: 28251)
// api 헤더
#include <windows.h>
#include <winuser.h>
#include <Shlwapi.h>
#define VC_EXTRALEAN
#define _WIN32_WINDOWS 0x0500
// library
#pragma comment(lib, "dxva2")
#pragma comment(lib, "user32")
#pragma comment(lib, "Shlwapi.lib")
// 모니터 vcp 관련 헤더
#include <lowlevelmonitorconfigurationapi.h>
#include <PhysicalMonitorEnumerationAPI.h>
#include <HighLevelMonitorConfigurationAPI.h>
// c++ 헤더
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include "json/json.h"
using namespace std;
// 구조체
typedef struct _MONITORPARAM
{
LPCWSTR szPhysicalMonitorDescription;
BYTE VCPcode;
int source;
BOOL bPowerOn;
int curVal;
} MONITOR_PARAM, * PMONITOR_PARAM;
typedef struct _GETMONITORINFO
{
LPCWSTR szPrimaryMonitorDescription;
LPCWSTR szSecondMonitorDescription;
int curVal;
int curVal_second;
} GET_MONITOR_INFO, * GET_PMONITOR_INFO;
// 얘를 모니터 개수만큼 포인터 배열로 만들어서 _getCurrentValue에 전달하면, 모니터 돌면서 정보를 채워줌.
typedef struct _MONITOR{
int num;
char * monName;
BYTE vcpValue;
} MONITOR, * PMONITOR;
// test 함수
VOID test_setInputSource(HWND, DWORD);
VOID test_getPrimaryMonitor(HWND);
VOID test_setInputSource(HWND, DWORD);
VOID test_SetMonitorPower(LPCWSTR, BOOL);
// interface 함수
VOID ChangeMonitorInput_hwan(PMONITOR mon, int monNum);
VOID GetMonitorInfo_hwan(PMONITOR mon, int monNum);
// callback 함수
BOOL CALLBACK MonitorEnumProc(HMONITOR, HDC, LPRECT, LPARAM);
BOOL CALLBACK _getCurrentValue(HMONITOR, HDC, LPRECT, LPARAM); // 얘가 모니터 정보 얻어와줌
BOOL CALLBACK _setMonitorInput(HMONITOR, HDC, LPRECT, LPARAM); // 얘는 특정 모니터에 value값 넣어줌.
// json 함수
void load_json(LPCWSTR file_path, PMONITOR mon, int monNum);
void save_json(LPCWSTR file_path, int monNum, Json::Value *monitor);
프로그램을 시작할 때 동일 경로 상의 vcp.json 파일 존재를 기준으로 mode를 정하는데, 해당 mode가 활성화 되어 있을 경우 UI는 나타나지 않고 vcp.json에 정의된 대상으로 변경만 수행한 뒤 프로그램을 종료한다. (최초에 한번 값을 넣어두면 다음부턴 실행했을 경우 모니터가 원하는 대상으로 변경된다. 개인적으로는 아래 작업 표시줄에 바로가기를 등록해두고 필요할때 눌러서 사용했다.)
여기에 등록해서 누르면 모니터가 바뀜
아래는 최초 실행 시 나오는 프로그램의 UI이다.
최초 실행 시 UI
현재는 연결된 모니터가 없어 노트북의 내장 모니터인 Generic PnP Monitor만 나오지만 다른 모니터가 연결되어 있을 경우, Dell H00000 (DP) 와 같은 식으로 모니터의 이름이 나온다. (제대로 나오지 않을 경우 장치 관리자의 모니터 탭에서 확인할 수 있다.)
참고로 이 부분이 모니터 개수만큼 아래로 늘어난다.
프로그램에서는 해당 문자열을 1번에 입력하여 여러개의 모니터들 중 변경을 원하는 대상을 구분할 수 있고 2번에는 연결된 케이블의 Value을 입력하여 원하는 케이블을 선택할 수 있다.
입력 후 Set 버튼을 누르면 모니터가 변경되고, Save를 누르면 동일 경로에 입력된 값으로 vcp.json 파일이 생성된다 이후 프로그램을 종료하면 실행 시 마다 vcp.json 내의 값을 기준으로 모니터가 변경된다. 만약 값 변경을 원하면 vcp.json을 직접 수정하거나 파일을 삭제하고 프로그램을 재실행해서 입력해주면 된다.
추가로 Value를 구하는 방법이 궁금하면 위의 GetVCPFeatureAndVCPFeatureReply() 함수를 잘 활용해서 구해보기 바란다.. (만약 귀찮으면 1부터 Max까지 값을 직접 넣어보면서 바뀌는 값을 찾는 방법도 있다.)
결과 및 느낀점
회사에 다니면서 주말 프로젝트로 간단하게 생각하고 진행했었는데, 자료를 찾다보니 점점 내용이 많아졌다.
어쨋든 기능적으로 나름 잘 돌아가는 프로그램을 만들었고 회사에서도 잘 사용을 하고 있기 때문에 마무리는 했지만, 중간에 작성했던 유용한 테스트 함수들이 계속 수정되면서 사라져 추가하지 못한 기능들이 많아 아쉬운 점이 많다. (Value 찾아주는 함수 등)
나중에 좀 더 기능을 추가해서 깔끔하게 업그레이드된 프로그램을 만들면 좋을 것 같다.
추가로 ddcutil? 라이브러리를 사용하면 dll을 통해 더 정리가 잘된 함수들을 사용할 수 있고 커맨드 형식으로도 제공이 되는 것 같다. (API로 삽질하지 말고 라이브러리 사용하면 편하다..) https://www.ddcutil.com/
CallBack 함수는 일반적인 함수와 비슷하지만, 호출되는 시점이 시스템(이벤트)에 의해 결정된다는 차이가 있다.
윈도우 API를 공부하면 가장 처음 배우는 Window를 띄우는 코드(링크)에서도 콜백 함수인 윈도우 프로시져를 볼 수 있는데, 해당 콜백함수(프로시져)는 WinMain에서 WndClass.lpfnWndProc=WndProc; 로 WNDCLASS 구조체에 등록된 뒤에 RegisterClass(&WndClass); 되어진 이후 따로 호출하지 않는다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace youtube_viewer
{
/// <summary>
/// MainWindow.xaml에 대한 상호 작용 논리
/// </summary>
public partial class MainWindow : Window
{
CefSharp.Wpf.ChromiumWebBrowser _browser;
string str;
public MainWindow()
{
InitializeComponent();
_browser = new CefSharp.Wpf.ChromiumWebBrowser();
GridViewer.Children.Add(_browser);
}
private void btn_play_Click(object sender, RoutedEventArgs e)
{
str = txtbox_url.Text.Split('=')[1].Split('&')[0];
if (this._browser != null)
{
_browser.Address = $"https://www.youtube-nocookie.com/embed/{str}";
}
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
// OpenCV 사용을 위한 using
using OpenCvSharp;
using OpenCvSharp.WpfExtensions;
// Timer 사용을 위한 using
using System.Windows.Threading;
namespace WPF
{
// OpenCvSharp 설치 시 Window를 명시적으로 사용해 주어야 함 (window -> System.Windows.Window)
public partial class MainWindow : System.Windows.Window
{
// 필요한 변수 선언
VideoCapture cam;
Mat frame;
DispatcherTimer timer;
bool is_initCam, is_initTimer;
public MainWindow()
{
InitializeComponent();
}
private void windows_loaded(object sender, RoutedEventArgs e)
{
// 카메라, 타이머(0.01ms 간격) 초기화
is_initCam = init_camera();
is_initTimer = init_Timer(0.01);
// 초기화 완료면 타이머 실행
if(is_initTimer && is_initCam) timer.Start();
}
private bool init_Timer(double interval_ms)
{
try
{
timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromMilliseconds(interval_ms);
timer.Tick += new EventHandler(timer_tick);
return true;
}
catch
{
return false;
}
}
private bool init_camera() {
try {
// 0번 카메라로 VideoCapture 생성 (카메라가 없으면 안됨)
cam = new VideoCapture(0);
cam.FrameHeight = (int)Cam_1.Height;
cam.FrameWidth = (int)Cam_1.Width;
// 카메라 영상을 담을 Mat 변수 생성
frame = new Mat();
return true;
}catch{
return false;
}
}
private void timer_tick(object sender, EventArgs e)
{
// 0번 장비로 생성된 VideoCapture 객체에서 frame을 읽어옴
cam.Read(frame);
// 읽어온 Mat 데이터를 Bitmap 데이터로 변경 후 컨트롤에 그려줌
Cam_1.Source = OpenCvSharp.WpfExtensions.WriteableBitmapConverter.ToWriteableBitmap(frame);
}
}
}