feat: Logitech K370 keyboard and M240 mouse Bluetooth monitor & auto-pairer with Topmost PIN alert
This commit is contained in:
+418
@@ -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<string, DeviceTarget> targets = new Dictionary<string, DeviceTarget>() {
|
||||||
|
{ "K370", new DeviceTarget("K370", "LOGI K370 B", true) },
|
||||||
|
{ "M240", new DeviceTarget("M240", "LOGI M240 B", false) }
|
||||||
|
};
|
||||||
|
|
||||||
|
static ConcurrentDictionary<string, DateTime> liveRfMacs = new ConcurrentDictionary<string, DateTime>();
|
||||||
|
static ConcurrentDictionary<string, DeviceInformation> uncollectedDevices = new ConcurrentDictionary<string, DeviceInformation>();
|
||||||
|
|
||||||
|
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<T>(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<DeviceInformationCustomPairing, DevicePairingRequestedEventArgs>(OnPairingRequested);
|
||||||
|
|
||||||
|
var kinds = DevicePairingKinds.ConfirmOnly |
|
||||||
|
DevicePairingKinds.ProvidePin |
|
||||||
|
DevicePairingKinds.ConfirmPinMatch |
|
||||||
|
DevicePairingKinds.DisplayPin;
|
||||||
|
|
||||||
|
var opPair = customPairing.PairAsync(kinds, DevicePairingProtectionLevel.Default);
|
||||||
|
var res = AwaitOp<DevicePairingResult>(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<BluetoothLEDevice>(opBle, 2000);
|
||||||
|
if (newBle != null) {
|
||||||
|
AttachPairedDevice(target, newBle);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 이전 구버전 MAC 인스턴스 정리
|
||||||
|
try {
|
||||||
|
var opAll = DeviceInformation.FindAllAsync(BluetoothLEDevice.GetDeviceSelector());
|
||||||
|
var allDevs = AwaitOp<DeviceInformationCollection>(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<BluetoothLEDevice>(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<DeviceUnpairingResult>(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<DeviceInformationCollection>(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<BluetoothLEDevice>(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<DeviceInformation> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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] ★ 블루투스 페어링 & 연결 완료!
|
||||||
|
```
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user