commit b345386887d02138e58e0e625041e98888c92cef Author: rl544 <111494264+rl544@users.noreply.github.com> Date: Tue Oct 13 13:00:13 2026 +0900 feat: Logitech K370 keyboard and M240 mouse Bluetooth monitor & auto-pairer with Topmost PIN alert diff --git a/BlePairer.cs b/BlePairer.cs new file mode 100644 index 0000000..957e98b --- /dev/null +++ b/BlePairer.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Windows.Devices.Enumeration; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; +using Windows.Foundation; + +class DeviceTarget { + public string Key; // "K370", "M240" + public string Name; // "LOGI K370 B", "LOGI M240 B" + public bool IsKeyboard; + public BluetoothLEDevice PairedBle; + public string Status = ""; // "CONNECTED", "PAIRED_WAITING", "SEARCHING_FOR_PAIR" + + public DeviceTarget(string key, string name, bool isKeyboard) { + this.Key = key; + this.Name = name; + this.IsKeyboard = isKeyboard; + } +} + +class BlePairer { + static MethodInfo asTaskGeneric = null; + static Dictionary targets = new Dictionary() { + { "K370", new DeviceTarget("K370", "LOGI K370 B", true) }, + { "M240", new DeviceTarget("M240", "LOGI M240 B", false) } + }; + + static ConcurrentDictionary liveRfMacs = new ConcurrentDictionary(); + static ConcurrentDictionary uncollectedDevices = new ConcurrentDictionary(); + + static object pairingLock = new object(); + static bool isPairingInProgress = false; + static string activePairingKey = ""; + + static BluetoothLEAdvertisementWatcher advWatcher = null; + static DeviceWatcher devWatcher = null; + static bool isScanningActive = false; + static object scanLock = new object(); + + static T AwaitOp(object asyncOp, int timeoutMs) { + if (asyncOp == null) return default(T); + if (asTaskGeneric == null) { + var methods = typeof(System.WindowsRuntimeSystemExtensions).GetMethods(); + foreach (var m in methods) { + if (m.Name == "AsTask" && m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType.Name == "IAsyncOperation`1") { + asTaskGeneric = m; + break; + } + } + } + var method = asTaskGeneric.MakeGenericMethod(typeof(T)); + object task = method.Invoke(null, new object[] { asyncOp }); + var waitMethod = task.GetType().GetMethod("Wait", new Type[] { typeof(int) }); + bool finished = (bool)waitMethod.Invoke(task, new object[] { timeoutMs }); + if (finished) { + var resultProp = task.GetType().GetProperty("Result"); + return (T)resultProp.GetValue(task, null); + } + return default(T); + } + + static string FormatMac(ulong rawAddr) { + string hex = rawAddr.ToString("X12").ToLower(); + return string.Format("{0}:{1}:{2}:{3}:{4}:{5}", + hex.Substring(0, 2), hex.Substring(2, 2), hex.Substring(4, 2), + hex.Substring(6, 2), hex.Substring(8, 2), hex.Substring(10, 2)); + } + + static DeviceTarget GetTargetForName(string name) { + if (string.IsNullOrEmpty(name)) return null; + string upper = name.ToUpper(); + if (upper.Contains("K370")) return targets["K370"]; + if (upper.Contains("M240")) return targets["M240"]; + return null; + } + + static void SetDeviceStatus(DeviceTarget target, string newStatus) { + lock (scanLock) { + if (target.Status != newStatus) { + target.Status = newStatus; + Console.WriteLine("STATUS:" + target.Key + ":" + newStatus); + UpdateScannerState(); + } + } + } + + static void UpdateScannerState() { + lock (scanLock) { + bool allConnected = true; + foreach (var t in targets.Values) { + if (t.Status != "CONNECTED") { + allConnected = false; + break; + } + } + + if (allConnected) { + StopScanners(); + } else { + StartScanners(); + } + } + } + + static void StartScanners() { + lock (scanLock) { + if (isScanningActive) return; + + try { + if (advWatcher != null) { + advWatcher.ScanningMode = BluetoothLEScanningMode.Passive; // Passive 모드: 전파 송출 없이 청취만 수행 + advWatcher.Start(); + } + if (devWatcher != null && devWatcher.Status != DeviceWatcherStatus.Started && devWatcher.Status != DeviceWatcherStatus.EnumerationCompleted) { + devWatcher.Start(); + } + isScanningActive = true; + Console.WriteLine("[DEBUG] Scanners started (Passive mode, zero radio contention)"); + } catch (Exception ex) { + Console.WriteLine("[DEBUG] StartScanners error: " + ex.Message); + } + } + } + + static void StopScanners() { + lock (scanLock) { + if (!isScanningActive) return; + + try { + if (advWatcher != null) { + advWatcher.Stop(); + } + if (devWatcher != null && (devWatcher.Status == DeviceWatcherStatus.Started || devWatcher.Status == DeviceWatcherStatus.EnumerationCompleted)) { + devWatcher.Stop(); + } + isScanningActive = false; + Console.WriteLine("[DEBUG] Scanners stopped (All devices connected, radio fully released)"); + } catch (Exception ex) { + Console.WriteLine("[DEBUG] StopScanners error: " + ex.Message); + } + } + } + + static void AttachPairedDevice(DeviceTarget target, BluetoothLEDevice ble) { + if (ble == null) return; + lock (scanLock) { + if (target.PairedBle != null && target.PairedBle.DeviceId != ble.DeviceId) { + try { target.PairedBle.Dispose(); } catch {} + } + target.PairedBle = ble; + target.PairedBle.ConnectionStatusChanged += (s, e) => { + Console.WriteLine("[DEBUG] " + target.Name + " ConnectionStatusChanged: " + s.ConnectionStatus); + if (s.ConnectionStatus == BluetoothConnectionStatus.Connected) { + SetDeviceStatus(target, "CONNECTED"); + } else { + SetDeviceStatus(target, "PAIRED_WAITING"); + } + }; + + Console.WriteLine("[DEBUG] Attached to " + target.Name + " (" + FormatMac(ble.BluetoothAddress) + "), Conn: " + ble.ConnectionStatus); + if (ble.ConnectionStatus == BluetoothConnectionStatus.Connected) { + SetDeviceStatus(target, "CONNECTED"); + } else { + SetDeviceStatus(target, "PAIRED_WAITING"); + } + } + } + + static void OnPairingRequested(DeviceInformationCustomPairing sender, DevicePairingRequestedEventArgs eventArgs) { + Console.WriteLine("[DEBUG] PairingRequested fired! Mode: " + eventArgs.PairingKind); + switch (eventArgs.PairingKind) { + case DevicePairingKinds.ConfirmOnly: + eventArgs.Accept(); + break; + case DevicePairingKinds.ProvidePin: + Console.WriteLine("[DEBUG] ProvidePin requested: using '0000'"); + eventArgs.Accept("0000"); + break; + case DevicePairingKinds.ConfirmPinMatch: + case DevicePairingKinds.DisplayPin: + if (!string.IsNullOrEmpty(eventArgs.Pin)) { + Console.WriteLine("PIN_REQUIRED:" + activePairingKey + ":" + eventArgs.Pin); + } + eventArgs.Accept(); + break; + default: + eventArgs.Accept(); + break; + } + } + + static void TryPairDevice(DeviceInformation dev, string mac, DeviceTarget target) { + lock (pairingLock) { + if (isPairingInProgress) return; + isPairingInProgress = true; + activePairingKey = target.Key; + } + + try { + Console.WriteLine("DEVICE_DETECTED:" + target.Key + ":" + mac); + Console.WriteLine("[DEBUG] Initiating pairing with live " + target.Name + ": " + mac); + + var customPairing = dev.Pairing.Custom; + customPairing.PairingRequested += new TypedEventHandler(OnPairingRequested); + + var kinds = DevicePairingKinds.ConfirmOnly | + DevicePairingKinds.ProvidePin | + DevicePairingKinds.ConfirmPinMatch | + DevicePairingKinds.DisplayPin; + + var opPair = customPairing.PairAsync(kinds, DevicePairingProtectionLevel.Default); + var res = AwaitOp(opPair, 60000); // Wait up to 60s for PIN if keyboard + + if (res != null && (res.Status == DevicePairingResultStatus.Paired || res.Status == DevicePairingResultStatus.AlreadyPaired)) { + Console.WriteLine("PAIRED_AND_CONNECTED:" + target.Key + ":" + mac); + + // 새 장치로 BluetoothLEDevice 바인딩 + try { + var opBle = BluetoothLEDevice.FromIdAsync(dev.Id); + var newBle = AwaitOp(opBle, 2000); + if (newBle != null) { + AttachPairedDevice(target, newBle); + } + } catch {} + + // 이전 구버전 MAC 인스턴스 정리 + try { + var opAll = DeviceInformation.FindAllAsync(BluetoothLEDevice.GetDeviceSelector()); + var allDevs = AwaitOp(opAll, 3000); + if (allDevs != null) { + foreach (var d in allDevs) { + if (d.Id != dev.Id && GetTargetForName(d.Name) == target) { + var opOld = BluetoothLEDevice.FromIdAsync(d.Id); + var oldBle = AwaitOp(opOld, 2000); + if (oldBle != null) { + if (oldBle.DeviceInformation.Pairing.IsPaired) { + string oldMac = FormatMac(oldBle.BluetoothAddress); + if (oldMac != mac) { + Console.WriteLine("[DEBUG] Cleaning up stale paired instance: " + oldMac); + var unpairOp = oldBle.DeviceInformation.Pairing.UnpairAsync(); + AwaitOp(unpairOp, 3000); + } + } + oldBle.Dispose(); + } + } + } + } + } catch {} + } else { + Console.WriteLine("[DEBUG] Pairing result for " + target.Name + ": " + (res != null ? res.Status.ToString() : "Timeout")); + } + } catch (Exception ex) { + Console.WriteLine("[DEBUG] TryPairDevice exception: " + ex.Message); + } finally { + lock (pairingLock) { + isPairingInProgress = false; + activePairingKey = ""; + } + } + } + + static void CheckAndTriggerPair(DeviceInformation dev, string mac) { + if (dev == null || string.IsNullOrEmpty(mac)) return; + var target = GetTargetForName(dev.Name); + if (target == null) return; + + DateTime lastSeen; + if (liveRfMacs.TryGetValue(mac, out lastSeen)) { + if ((DateTime.UtcNow - lastSeen).TotalSeconds < 10) { + new Thread(() => TryPairDevice(dev, mac, target)).Start(); + } + } + } + + static void RefreshPairedDevices() { + try { + var op = DeviceInformation.FindAllAsync(BluetoothLEDevice.GetDeviceSelector()); + var devices = AwaitOp(op, 2000); + if (devices != null) { + foreach (var dev in devices) { + var target = GetTargetForName(dev.Name); + if (target != null) { + var opBle = BluetoothLEDevice.FromIdAsync(dev.Id); + var ble = AwaitOp(opBle, 2000); + if (ble != null) { + if (ble.DeviceInformation.Pairing.IsPaired) { + if (target.PairedBle == null || target.PairedBle.DeviceId != ble.DeviceId) { + AttachPairedDevice(target, ble); + } else { + if (target.PairedBle.ConnectionStatus == BluetoothConnectionStatus.Connected) { + SetDeviceStatus(target, "CONNECTED"); + } else { + SetDeviceStatus(target, "PAIRED_WAITING"); + } + ble.Dispose(); + } + } else { + ble.Dispose(); + } + } + } + } + } + } catch (Exception ex) { + Console.WriteLine("[DEBUG] RefreshPairedDevices error: " + ex.Message); + } + + foreach (var t in targets.Values) { + if (t.PairedBle == null) { + SetDeviceStatus(t, "SEARCHING_FOR_PAIR"); + } + } + } + + static void RunMonitorDaemon() { + Console.WriteLine("[DEBUG] Initializing BlePairer multi-device daemon (K370 Keyboard + M240 Mouse)..."); + + // 1. Passive Advertisement Watcher (무선 송신 없이 신호만 청취) + advWatcher = new BluetoothLEAdvertisementWatcher(); + advWatcher.ScanningMode = BluetoothLEScanningMode.Passive; + advWatcher.Received += (s, e) => { + string rawMac = e.BluetoothAddress.ToString("X12").ToLower(); + string macWithColons = FormatMac(e.BluetoothAddress); + DateTime now = DateTime.UtcNow; + liveRfMacs[macWithColons] = now; + liveRfMacs[rawMac] = now; + + DeviceInformation pendingDev; + if (uncollectedDevices.TryGetValue(macWithColons, out pendingDev)) { + CheckAndTriggerPair(pendingDev, macWithColons); + } + }; + + // 2. DeviceWatcher + string[] requestedProps = { + "System.Devices.Aep.DeviceAddress", + "System.Devices.Aep.IsConnected", + "System.Devices.Aep.IsPresent", + "System.Devices.Aep.CanPair", + "System.Devices.Aep.IsPaired", + "System.Devices.Aep.SignalStrength" + }; + string selector = "System.Devices.DevObjectType:=5 AND System.Devices.Aep.ProtocolId:=\"{BB7BB05E-5972-42B5-94FC-76EAA7084D49}\""; + devWatcher = DeviceInformation.CreateWatcher(selector, requestedProps, DeviceInformationKind.AssociationEndpoint); + + Action handleDevice = (dev) => { + if (dev == null || string.IsNullOrEmpty(dev.Name)) return; + var target = GetTargetForName(dev.Name); + if (target == null) return; + + string addr = ""; + if (dev.Properties.ContainsKey("System.Devices.Aep.DeviceAddress") && dev.Properties["System.Devices.Aep.DeviceAddress"] != null) { + addr = dev.Properties["System.Devices.Aep.DeviceAddress"].ToString().ToLower(); + } + + bool isPaired = dev.Pairing != null && dev.Pairing.IsPaired; + bool canPair = dev.Pairing != null && dev.Pairing.CanPair; + + if (!isPaired && canPair && !string.IsNullOrEmpty(addr)) { + uncollectedDevices[addr] = dev; + CheckAndTriggerPair(dev, addr); + } + }; + + devWatcher.Added += (s, dev) => handleDevice(dev); + devWatcher.Updated += (s, upd) => { + foreach (var kv in uncollectedDevices) { + if (upd.Id.ToLower().Contains(kv.Key.Replace(":", ""))) { + CheckAndTriggerPair(kv.Value, kv.Key); + } + } + }; + + // 초기 시스템 등록 상태 확인 + RefreshPairedDevices(); + + // 메인 폴링 루프 (2초 주기): 이벤트 누락 방지용 가벼운 헬스체크 + while (true) { + Thread.Sleep(2000); + + foreach (var t in targets.Values) { + if (t.PairedBle != null) { + var connStatus = t.PairedBle.ConnectionStatus; + if (connStatus == BluetoothConnectionStatus.Connected) { + SetDeviceStatus(t, "CONNECTED"); + } else { + SetDeviceStatus(t, "PAIRED_WAITING"); + } + } else { + RefreshPairedDevices(); + } + } + } + } + + static int Main(string[] args) { + try { + if (args.Length >= 1 && args[0].ToLower() == "monitor") { + RunMonitorDaemon(); + return 0; + } + + RefreshPairedDevices(); + foreach (var t in targets.Values) { + Console.WriteLine(t.Key + ":" + t.Status); + } + return 0; + } catch (Exception ex) { + Console.WriteLine("[DEBUG] Main exception: " + ex.ToString()); + return 99; + } + } +} diff --git a/BlePairer.exe b/BlePairer.exe new file mode 100644 index 0000000..78fa6dd Binary files /dev/null and b/BlePairer.exe differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fadbca --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Logitech Bluetooth Monitor (LOGI K370 B & LOGI M240 B) + +Windows 환경에서 로지텍 블루투스 키보드(**LOGI K370 B**)와 마우스(**LOGI M240 B**)의 연결 상태를 실시간 모니터링하고, 페어링 모드 진입 시 자동 페어링 및 상시 대기/재연결을 관리하는 고속 네이티브 백그라운드 모니터 프로그램입니다. + +--- + +## 주요 기능 + +1. **멀티 디바이스 실시간 감시 및 자동 복구**: + - **LOGI K370 B** (키보드) & **LOGI M240 B** (마우스)의 연결 상태를 2초 주기로 헬스체크 및 이벤트 기반 실시간 감지. + - 키보드/마우스 전원을 끄거나 채널을 돌려 연결이 해제되면 즉시 `대기 모드`로 복귀하여 재연결 또는 새 페어링을 대기합니다. +2. **공식 WinRT DeviceWatcher + Passive RF 무선 전파 하이브리드 감지**: + - Windows 설정의 '장치 추가' 창과 동일한 공식 WinRT `DeviceWatcher` API로 디바이스 식별. + - `BluetoothLEAdvertisementWatcher`의 **Passive 스캔 모드**를 사용하여 무선 전파를 송출하지 않고 청취만 수행하므로, 블루투스 마우스나 타 기기 연결에 간섭(지연/끊김)을 0%로 차단합니다. + - 키보드와 마우스가 모두 연결된 정상 동작 중에는 스캐너 자체를 **완전 정지(Stop)**시켜 블루투스 대역폭을 100% 확보합니다. +3. **키보드 PIN 감지 시 자동 최상위(Topmost) 창 팝업**: + - 사용자가 다른 프로그램을 사용 중이거나 콘솔 창이 최소화되어 있어도, 키보드 신호 및 6자리 PIN 번호 감지 즉시 창을 **원래 크기로 복원하고 화면 최상위(`HWND_TOPMOST`)로 자동 팝업**합니다. + - 키보드에서 PIN 번호 입력 및 Enter 완료 시 3초 후 최상위 고정을 자동 해제하여 원활한 작업 환경을 보장합니다. +4. **마우스 완전 무인 자동 페어링**: + - 마우스(M240)는 PIN 번호 입력이 필요 없는 `ConfirmOnly` 규격으로, 페어링 버튼 활성화 시 1초 만에 시스템 레벨에서 무인 자동 승인되어 즉시 연결됩니다. + +--- + +## 구성 파일 + +- `main.go` / `logi_k370_monitor.exe`: Win32 Topmost 제어, 로그 출력, 백그라운드 프로세스 생명주기를 관리하는 Go 메인 실행 파일. +- `BlePairer.cs` / `BlePairer.exe`: Windows.Devices.Bluetooth / WinRT 네이티브 API를 활용한 블루투스 LE 모니터 및 페어링 C# 백엔드 엔진. +- `go.mod`: Go 모듈 정의 파일. + +--- + +## 빌드 방법 + +### 1. C# 엔진 빌드 (BlePairer.exe) +Windows 기본 .NET Framework 컴파일러(`csc.exe`)를 사용하여 빌드합니다: + +```powershell +& "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe" ` + /r:"C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Runtime\v4.0_4.0.0.0__b03f5f7f11d50a3a\System.Runtime.dll" ` + /r:"C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Runtime.InteropServices.WindowsRuntime\v4.0_4.0.0.0__b03f5f7f11d50a3a\System.Runtime.InteropServices.WindowsRuntime.dll" ` + /r:"C:\Windows\System32\WinMetadata\Windows.Devices.winmd" ` + /r:"C:\Windows\System32\WinMetadata\Windows.Foundation.winmd" ` + /r:System.Runtime.WindowsRuntime.dll ` + /out:BlePairer.exe BlePairer.cs +``` + +### 2. Go 모니터 빌드 (logi_k370_monitor.exe) +```powershell +go build -o logi_k370_monitor.exe main.go +``` + +--- + +## 실행 방법 + +```powershell +.\logi_k370_monitor.exe +``` + +- **키보드/마우스 정상 연결 시**: + ```text + [LOGI K370 B] ★ 정상 연결되었습니다 (동작 중) + [LOGI M240 B] ★ 정상 연결되었습니다 (동작 중) + ``` +- **키보드 페어링 모드(채널 버튼 3초 롱프레스) 시**: + 창이 화면 맨 앞으로 팝업되며 PIN 번호가 표시됩니다: + ```text + [LOGI K370 B] ★ 키보드 신호 포착! 창을 최상위(Topmost)로 띄웁니다... (d3:c9:dd:64:f1:xx) + ====================================================== + ★ [LOGI K370 B] 페어링 번호 감지! 키보드에서 [123456] 입력 후 [Enter]를 누르세요! + ====================================================== + ``` +- **마우스 페어링 모드(전원/페어링 버튼 활성화) 시**: + ```text + [LOGI M240 B] ★ 마우스 신호 포착! 자동 페어링을 진행합니다... (xx:xx:xx:xx:xx:xx) + [LOGI M240 B] ★ 블루투스 페어링 & 연결 완료! + ``` diff --git a/btpair.exe b/btpair.exe new file mode 100644 index 0000000..4f25e7c Binary files /dev/null and b/btpair.exe differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..efce8e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/hana/logi_k370_monitor + +go 1.22 diff --git a/logi_k370_monitor.exe b/logi_k370_monitor.exe new file mode 100644 index 0000000..cc321b1 Binary files /dev/null and b/logi_k370_monitor.exe differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..5201e14 --- /dev/null +++ b/main.go @@ -0,0 +1,235 @@ +package main + +import ( + "bufio" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId") + procShowWindow = user32.NewProc("ShowWindow") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procSetWindowPos = user32.NewProc("SetWindowPos") + procGetAncestor = user32.NewProc("GetAncestor") + procIsIconic = user32.NewProc("IsIconic") + procBringWindowToTop = user32.NewProc("BringWindowToTop") + procGetForegroundWindow = user32.NewProc("GetForegroundWindow") + procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") + procAttachThreadInput = user32.NewProc("AttachThreadInput") +) + +const ( + SW_RESTORE = 9 + SW_SHOW = 5 + GA_ROOTOWNER = 3 + SWP_NOMOVE = 0x0002 + SWP_NOSIZE = 0x0001 + SWP_SHOWWINDOW = 0x0040 +) + +func getTargetWindow() uintptr { + hwnd, _, _ := procGetConsoleWindow.Call() + if hwnd == 0 { + return 0 + } + root, _, _ := procGetAncestor.Call(hwnd, GA_ROOTOWNER) + if root != 0 { + return root + } + return hwnd +} + +func bringWindowToTopmost(topmost bool) { + hwnd := getTargetWindow() + if hwnd == 0 { + return + } + + // 1. 최소화되어 있는 경우 창 복원 + isMin, _, _ := procIsIconic.Call(hwnd) + if isMin != 0 { + procShowWindow.Call(hwnd, SW_RESTORE) + } else { + procShowWindow.Call(hwnd, SW_SHOW) + } + + // 2. HWND_TOPMOST (-1) 또는 HWND_NOTOPMOST (-2) 적용 + var insertAfter uintptr + if topmost { + insertAfter = ^uintptr(0) // -1 (HWND_TOPMOST) + } else { + insertAfter = ^uintptr(1) // -2 (HWND_NOTOPMOST) + } + procSetWindowPos.Call(hwnd, insertAfter, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE|SWP_SHOWWINDOW) + + // 3. 포커스 전환 및 최상위 활성화 + fgWnd, _, _ := procGetForegroundWindow.Call() + if fgWnd != 0 && fgWnd != hwnd { + fgThread, _, _ := procGetWindowThreadProcessId.Call(fgWnd, 0) + curThread, _, _ := procGetCurrentThreadId.Call() + if fgThread != curThread { + procAttachThreadInput.Call(curThread, fgThread, 1) + procSetForegroundWindow.Call(hwnd) + procBringWindowToTop.Call(hwnd) + procAttachThreadInput.Call(curThread, fgThread, 0) + } else { + procSetForegroundWindow.Call(hwnd) + procBringWindowToTop.Call(hwnd) + } + } else { + procSetForegroundWindow.Call(hwnd) + procBringWindowToTop.Call(hwnd) + } +} + +func getPairerExePath() string { + exePath, err := os.Executable() + if err != nil { + return "BlePairer.exe" + } + dir := filepath.Dir(exePath) + return filepath.Join(dir, "BlePairer.exe") +} + +func getDeviceDisplayName(key string) string { + switch key { + case "K370": + return "LOGI K370 B" + case "M240": + return "LOGI M240 B" + default: + return key + } +} + +func runMonitor() { + pairerExe := getPairerExePath() + + for { + log.Println("Starting Logitech Bluetooth monitor daemon (LOGI K370 B + LOGI M240 B)...") + cmd := exec.Command(pairerExe, "monitor") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + + stdout, err := cmd.StdoutPipe() + if err != nil { + log.Printf("Stdout 파이프 연결 오류: %v", err) + time.Sleep(2 * time.Second) + continue + } + + stderr, err := cmd.StderrPipe() + if err == nil { + go io.Copy(os.Stderr, stderr) + } + + if err := cmd.Start(); err != nil { + log.Printf("BlePairer 실행 실패: %v", err) + time.Sleep(2 * time.Second) + continue + } + + scanner := bufio.NewScanner(stdout) + lastStatusMap := make(map[string]string) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + if strings.HasPrefix(line, "[DEBUG]") { + log.Println(line) + } else if strings.HasPrefix(line, "STATUS:") { + parts := strings.Split(strings.TrimPrefix(line, "STATUS:"), ":") + if len(parts) == 2 { + key := parts[0] + status := parts[1] + devName := getDeviceDisplayName(key) + + if lastStatusMap[key] != status { + switch status { + case "CONNECTED": + log.Printf("[%s] ★ 정상 연결되었습니다 (동작 중)", devName) + case "PAIRED_WAITING": + log.Printf("[%s] 연결 해제됨 -> 대기 모드 (재연결 또는 페어링 신호 대기)", devName) + case "SEARCHING_FOR_PAIR": + if key == "K370" { + log.Printf("[%s] 등록 대기 중 (채널 버튼을 3초간 길게 누르세요)", devName) + } else { + log.Printf("[%s] 등록 대기 중 (마우스 전원/페어링 버튼을 누르세요)", devName) + } + default: + log.Printf("[%s] 상태 변경: %s", devName, status) + } + lastStatusMap[key] = status + } + } + } else if strings.HasPrefix(line, "DEVICE_DETECTED:") { + parts := strings.Split(strings.TrimPrefix(line, "DEVICE_DETECTED:"), ":") + if len(parts) >= 2 { + key := parts[0] + mac := parts[1] + devName := getDeviceDisplayName(key) + + if key == "K370" { + log.Printf("[%s] ★ 키보드 신호 포착! 창을 최상위(Topmost)로 띄웁니다... (%s)", devName, mac) + bringWindowToTopmost(true) + } else { + log.Printf("[%s] ★ 마우스 신호 포착! 자동 페어링을 진행합니다... (%s)", devName, mac) + } + } + } else if strings.HasPrefix(line, "PIN_REQUIRED:") { + parts := strings.Split(strings.TrimPrefix(line, "PIN_REQUIRED:"), ":") + if len(parts) >= 2 { + key := parts[0] + pin := parts[1] + devName := getDeviceDisplayName(key) + + bringWindowToTopmost(true) + log.Printf("======================================================") + log.Printf("★ [%s] 페어링 번호 감지! 키보드에서 [%s] 입력 후 [Enter]를 누르세요!", devName, pin) + log.Printf("======================================================") + } + } else if strings.HasPrefix(line, "PAIRED_AND_CONNECTED:") { + parts := strings.Split(strings.TrimPrefix(line, "PAIRED_AND_CONNECTED:"), ":") + if len(parts) >= 2 { + key := parts[0] + mac := parts[1] + devName := getDeviceDisplayName(key) + + log.Printf("======================================================") + log.Printf("[%s] ★ 블루투스 페어링 & 연결 완료! (%s)", devName, mac) + log.Printf("======================================================") + + if key == "K370" { + go func() { + time.Sleep(3 * time.Second) + bringWindowToTopmost(false) + }() + } + } + } else { + log.Println(line) + } + } + + cmd.Wait() + log.Printf("BlePairer 데몬 종료됨. 2초 후 재시작합니다...") + time.Sleep(2 * time.Second) + } +} + +func main() { + runMonitor() +} diff --git a/monitor_logic.ps1 b/monitor_logic.ps1 new file mode 100644 index 0000000..563bb28 --- /dev/null +++ b/monitor_logic.ps1 @@ -0,0 +1,70 @@ +# Monitor and pairing script for LOGI K370 B +$deviceName = "LOGI K370 B" + +# 1. 시스템에 등록된 LOGI K370 B 장치 조회 +$devs = @(Get-PnpDevice -FriendlyName "*$deviceName*" -ErrorAction SilentlyContinue) + +$hasConnected = $false +$connectedInstance = $null + +foreach ($d in $devs) { + $prop = Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName "{83DA6326-97A6-4088-9453-A1923F573B29} 15" -ErrorAction SilentlyContinue + $isConn = ($prop -and $prop.Data -eq $true) + if ($isConn) { + $hasConnected = $true + $connectedInstance = $d + } +} + +function Format-Mac($raw) { + $parts = @() + for ($i = 0; $i -lt $raw.Length; $i += 2) { + $parts += $raw.Substring($i, 2).ToLower() + } + return [string]::Join(":", $parts) +} + +# 2. 이미 정상 연결된 경우 -> 과거 잔여 인스턴스가 있다면 정리하고 CONNECTED 반환 +if ($hasConnected) { + foreach ($d in $devs) { + if ($d.InstanceId -ne $connectedInstance.InstanceId) { + $addr = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName "DEVPKEY_Bluetooth_DeviceAddress" -ErrorAction SilentlyContinue).Data + if ($addr) { + $fMac = Format-Mac $addr + & .\btpair.exe unpair-by-mac --mac $fMac --type BluetoothLE | Out-Null + } + } + } + Write-Output "CONNECTED" + exit 0 +} + +# 3. 연결 해제 상태: 과거 잔여 페어링 unpair 정리 +foreach ($d in $devs) { + $addr = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName "DEVPKEY_Bluetooth_DeviceAddress" -ErrorAction SilentlyContinue).Data + if ($addr) { + $fMac = Format-Mac $addr + Write-Output "[DEBUG] Unpairing stale instance: $fMac" + & .\btpair.exe unpair-by-mac --mac $fMac --type BluetoothLE | Out-Null + } +} + +# 4. 실시간 BLE 광고 스캔 및 발견 즉시 자동 페어링 시도 (스캔 2초, PIN 입력 대기 45초) +$pairRes = & .\BlePairer.exe watch-and-pair 2 45 + +foreach ($outLine in $pairRes) { + if ($outLine -match "Active advertisement detected|Device found|PairingRequested|SUCCESS|FAILED|Timeout|Exception") { + Write-Output "[DEBUG] $outLine" + } + if ($outLine -match "PIN_CODE:(\d+)") { + Write-Output "PIN_REQUIRED: $($Matches[1])" + } +} + +$allOut = $pairRes -join ' ' +if ($allOut -match "SUCCESS") { + Write-Output "PAIRED_AND_CONNECTED" + exit 0 +} + +Write-Output "CLEANED_AND_WAITING" diff --git a/test_ble.ps1 b/test_ble.ps1 new file mode 100644 index 0000000..e6f3457 --- /dev/null +++ b/test_ble.ps1 @@ -0,0 +1,12 @@ +[Windows.Devices.Bluetooth.BluetoothLEDevice,Windows.Devices.Bluetooth,ContentType=WindowsRuntime] | Out-Null +$op = [Windows.Devices.Bluetooth.BluetoothLEDevice]::FromBluetoothAddressAsync(232857434550649) +while ($op.Status -eq [Windows.Foundation.AsyncStatus]::Started) { Start-Sleep -Milliseconds 100 } +$dev = $op.GetResults() +Write-Host ("Dev: " + $dev) +if ($dev) { + Write-Host ("ConnectionStatus: " + $dev.ConnectionStatus) + $gattOp = $dev.GetGattServicesAsync([Windows.Devices.Bluetooth.BluetoothCacheMode]::Uncached) + while ($gattOp.Status -eq [Windows.Foundation.AsyncStatus]::Started) { Start-Sleep -Milliseconds 100 } + $gatt = $gattOp.GetResults() + Write-Host ("Gatt status: " + $gatt.Status + " Services count: " + $gatt.Services.Count) +}