{*******************************************************}
{                                                       }
{       Tocsg.Bluetooth                                 }
{                                                       }
{       Copyright (C) 2022 kku                          }
{                                                       }
{*******************************************************}

unit Tocsg.Bluetooth;

interface

uses
  EM.jwabluetoothapis, Tocsg.Obj, Tocsg.Thread, System.Classes,
  System.SysUtils, Winapi.Windows, System.Generics.Collections;

const
  BT_DEVICE_CLASS_MISC           = $0;
  BT_DEVICE_CLASS_COMPUTER       = $1;
  BT_DEVICE_CLASS_PHONE          = $2;
  BT_DEVICE_CLASS_LANACCESSPOINT = $3;
  BT_DEVICE_CLASS_AV             = $4;
  BT_DEVICE_CLASS_PERIPHERAL     = $5;
  BT_DEVICE_CLASS_IMAGING        = $6;
  BT_DEVICE_CLASS_UNCLASSIFIED   = $1F;

type
  PBtDevEnt = ^TBtDevEnt;
  TBtDevEnt = record
    sAddress: String;
    dtLastSeen,
    dtLastUsed: TDateTime;
    dInfo: BLUETOOTH_DEVICE_INFO;
  end;

  TBluetoothDevice = class(TTgObject)
  protected
    BTDeviceList_: TList<PBtDevEnt>;
    procedure OnBTDeviceNotify(Sender: TObject; const Item: PBtDevEnt;
                              Action: TCollectionNotification);
    function GetCount: Integer;
    function GetBTDeviceByIndex(nIndex: Integer): PBtDevEnt;
  public
    Constructor Create;
    Destructor Destroy; override;

    function RefreshBTDevice(pbWorkStop: PBoolean = nil): Boolean;

    property Count: Integer read GetCount;
    property Items[nIndex: Integer]: PBtDevEnt read GetBTDeviceByIndex; default;
  end;

  PBtRdiEnt = ^TBtRdiEnt;
  TBtRdiEnt = record
    sAddress: String;
    dInfo: BLUETOOTH_RADIO_INFO;
  end;

  TBluetoothRadio = class(TTgObject)
  protected
    BTRadioList_: TList<PBtRdiEnt>;
    procedure OnBTRadioNotify(Sender: TObject; const Item: PBtRdiEnt;
                              Action: TCollectionNotification);
    function GetCount: Integer;
    function GetBTRadioByIndex(nIndex: Integer): PBtRdiEnt;
  public
    Constructor Create;
    Destructor Destroy; override;

    function RefreshBTRadio: Boolean;

    property Count: Integer read GetCount;
    property Items[nIndex: Integer]: PBtRdiEnt read GetBTRadioByIndex; default;
  end;

  TBTChangeState = (csDetection, csConnected, csRemembered, csAuthenticated, csLastSeen, csLastUsed);
  TBTChangeStates = set of TBTChangeState;

  TBtDevChangeNotify = procedure(pEnt: PBtDevEnt; csBT: TBTChangeStates; var bPrevent: Boolean) of object;

  TThdBtDevNotify = class(TTgThread)
  private
    bSync_: Boolean;
    BTDevice_: TBluetoothDevice;
    DcBTDevice_: TDictionary<String,PBtDevEnt>;

    procedure OnBTDeviceNotify(Sender: TObject; const Item: PBtDevEnt;
                              Action: TCollectionNotification);
  protected
    evChangeBTDevice_: TBtDevChangeNotify;

    pBTEntry_: PBtDevEnt;
    csBT_: TBTChangeStates;
    bPreventBtDevs_: Boolean;

    function GetBTDeviceState(aEnt: PBtDevEnt): TBTChangeStates;
    procedure Execute; override;

    procedure ProcessBTDeviceNotify;
  public
    Constructor Create(bSync: Boolean = false);
    Destructor Destroy; override;

    procedure ResetBTDevice;

    property OnChangeBTDevice: TBtDevChangeNotify write evChangeBTDevice_;
    property PreventBtDevs: Boolean write bPreventBtDevs_;
  end;

  procedure BtDevTypeToStr(dwClassOfDevice: DWORD; var sMajor, sMinor: String);

function SetBtDevsEnable(bVal: Boolean): Integer;

implementation

uses
  System.DateUtils, Tocsg.DateTime, Tocsg.Strings, Tocsg.Driver, Tocsg.Convert,
  Tocsg.Exception;

function SetBtDevsEnable(bVal: Boolean): Integer;
var
  hDev: HDEVINFO;
  sdd: TSPDevInfoData;
  i: Integer;
  dwBufSize,
  dwStatus, dwProblem,
  dwPropertyRegDataType: DWORD;
  pBuf: Pointer;
begin
  Result := 0;

  try
    hDev := SetupDiGetClassDevs(@GUID_DEVCLASS_BLUETOOTH, nil, 0, DIGCF_PRESENT);
    if hDev = INVALID_HANDLE_VALUE then
      exit;

    pBuf := nil;
    try
      ZeroMemory(@sdd, SizeOf(sdd));
      sdd.cbSize := SizeOf(sdd);

      i := 0;
      while SetupDiEnumDeviceInfo(hDev, i, sdd) do
      begin
        dwBufSize := 0;
        if pBuf <> nil then
        begin
          FreeMem(pBuf);
          pBuf := nil;
        end;

        while not SetupDiGetDeviceRegistryProperty(hDev, sdd,
                 SPDRP_HARDWAREID, dwPropertyRegDataType, pBuf, dwBufSize, dwBufSize) do
        begin
          if GetLastError = ERROR_INSUFFICIENT_BUFFER then
          begin
            if pBuf <> nil then
              FreeMem(pBuf);
            pBuf := AllocMem(dwBufSize);
          end else break;
        end;

        if pBuf <> nil then
        begin
          if (CompareText('BTH\MS_BTHLE', String(PChar(pBuf))) <> 0) and
             (CompareText('BTH\MS_BTHBRB', String(PChar(pBuf))) <> 0) then
          begin
            dwStatus := 0;
            dwProblem := 0;

            if CM_Get_DevNode_Status(dwStatus, dwProblem, sdd.DevInst, 0) = CR_SUCCESS then
            begin
//              var bDisabled: Boolean := (((dwStatus and DN_HAS_PROBLEM) = 0) and (dwProblem = CM_PROB_DISABLED));
//              if bVal = bDisabled then
              begin
                var PropChangeParams: TSPPropChangeParams;
                ZeroMemory(@PropChangeParams, SizeOf(PropChangeParams));
                PropChangeParams.ClassInstallHeader.cbSize := SizeOf(TSPClassInstallHeader);
                PropChangeParams.ClassInstallHeader.InstallFunction := DIF_PROPERTYCHANGE;
                PropChangeParams.Scope := DICS_FLAG_GLOBAL;
                PropChangeParams.StateChange := BooleanToInt(bVal, DICS_ENABLE, DICS_DISABLE);

                if SetupDiSetClassInstallParams(hDev, @sdd, PSPClassInstallHeader(@PropChangeParams), SizeOf(PropChangeParams)) then
                begin
                // 64bit OS 에서는 64bit 프로그램 에서만 실행 가능하다 22_0621 13:59:21 kku
                  if SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, hDev, @sdd) then
                    Inc(Result);
                end;
              end;
            end;
          end;
        end;

        Inc(i);
      end;

    finally
      SetupDiDestroyDeviceInfoList(hDev);
      if pBuf <> nil then
        FreeMem(pBuf);
    end;
  except
    on E: Exception do
      ETgException.TraceException(E, 'Fail .. SetBtDevsEnable()');
  end;
end;

{ TBluetoothDevice }

Constructor TBluetoothDevice.Create;
begin
  Inherited Create;
  BTDeviceList_ := TList<PBtDevEnt>.Create;
  BTDeviceList_.OnNotify := OnBTDeviceNotify;
end;

Destructor TBluetoothDevice.Destroy;
begin
  FreeAndNil(BTDeviceList_);
  Inherited;
end;

procedure TBluetoothDevice.OnBTDeviceNotify(Sender: TObject; const Item: PBtDevEnt;
                                              Action: TCollectionNotification);
begin
  case Action of
    cnAdded: ;
    cnRemoved: Dispose(Item);
    cnExtracted: ;
  end;
end;

function TBluetoothDevice.GetCount: Integer;
begin
  Result := BTDeviceList_.Count;
end;

function TBluetoothDevice.GetBTDeviceByIndex(nIndex: Integer): PBtDevEnt;
begin
  Result := nil;
  if (nIndex >= 0) and (nIndex < BTDeviceList_.Count) then
    Result := BTDeviceList_[nIndex];
end;

function TBluetoothDevice.RefreshBTDevice(pbWorkStop: PBoolean = nil): Boolean;
var
  hFind: HBLUETOOTH_DEVICE_FIND;
  BtDevSchParam: BLUETOOTH_DEVICE_SEARCH_PARAMS;
  BtDevInfo: BLUETOOTH_DEVICE_INFO;
  pInfo: PBtDevEnt;
begin
  Result := false;

  BTDeviceList_.Clear;

  ZeroMemory(@BtDevSchParam, SizeOf(BtDevSchParam));
  BtDevSchParam.dwSize := SizeOf(BtDevSchParam);
  BtDevSchParam.fReturnAuthenticated := true;
  BtDevSchParam.fReturnRemembered := true;
  BtDevSchParam.fReturnUnknown := true;
  BtDevSchParam.fReturnConnected := true;
//  BtDevSchParam.fIssueInquiry := true;
//  BtDevSchParam.cTimeoutMultiplier := 10;

  ZeroMemory(@BtDevInfo, SizeOf(BtDevInfo));
  BtDevInfo.dwSize := SizeOf(BtDevInfo);
  hFind := BluetoothFindFirstDevice(BtDevSchParam, BtDevInfo);

  try
    if hFind <> 0 then
    begin
      repeat
        New(pInfo);
        ZeroMemory(pInfo, SizeOf(TBtDevEnt));

        BluetoothUpdateDeviceRecord(BtDevInfo);
        pInfo.dInfo := BtDevInfo;
        pInfo.sAddress := Format('%.2x:%.2x:%.2x:%.2x:%.2x:%.2x',
              [BtDevInfo.Address.rgBytes[5], BtDevInfo.Address.rgBytes[4],
               BtDevInfo.Address.rgBytes[3], BtDevInfo.Address.rgBytes[2],
               BtDevInfo.Address.rgBytes[1], BtDevInfo.Address.rgBytes[0]]);

        pInfo.dtLastSeen := ConvSystemTimeToDateTime_Local(BtDevInfo.stLastSeen);
        try
          if BtDevInfo.stLastUsed.wYear <> 0 then
            pInfo.dtLastUsed := ConvSystemTimeToDateTime_Local(BtDevInfo.stLastUsed)
          else
            pInfo.dtLastUsed := 0;
        except
          pInfo.dtLastUsed := 0;
        end;
        BTDeviceList_.Add(pInfo);

        if (pbWorkStop <> nil) and (pbWorkStop^ = true) then
          exit;
      until (hFind <> 0) and not BluetoothFindNextDevice(hFind, BtDevInfo);
      Result := true;
    end;
  finally
    if hFind <> 0 then
    begin
      BluetoothFindDeviceClose(hFind);
    end;
  end;
end;

{ TBluetoothRadio }

Constructor TBluetoothRadio.Create;
begin
  BTRadioList_ := TList<PBtRdiEnt>.Create;
  BTRadioList_.OnNotify := OnBTRadioNotify;
end;

Destructor TBluetoothRadio.Destroy;
begin
  FreeAndNil(BTRadioList_);
  Inherited;
end;

procedure TBluetoothRadio.OnBTRadioNotify(Sender: TObject; const Item: PBtRdiEnt;
                        Action: TCollectionNotification);
begin
  if Action = cnRemoved then
    Dispose(Item);
end;

function TBluetoothRadio.GetCount: Integer;
begin
  Result := BTRadioList_.Count;
end;

function TBluetoothRadio.GetBTRadioByIndex(nIndex: Integer): PBtRdiEnt;
begin
  Result := nil;
  if (nIndex >= 0) and (nIndex < BTRadioList_.Count) then
    Result := BTRadioList_[nIndex];
end;

function TBluetoothRadio.RefreshBTRadio: Boolean;
var
  hFind: HBLUETOOTH_RADIO_FIND;
  hRadio: THandle;
  BtRadiFindParam: BLUETOOTH_FIND_RADIO_PARAMS;
  BtRadiInfo: BLUETOOTH_RADIO_INFO;
  pInfo: PBtRdiEnt;
begin
  Result := false;

  BTRadioList_.Clear;

  ZeroMemory(@BtRadiFindParam, SizeOf(BtRadiFindParam));
  BtRadiFindParam.dwSize := SizeOf(BtRadiFindParam);
  ZeroMemory(@BtRadiInfo, SizeOf(BtRadiInfo));
  BtRadiInfo.dwSize := SizeOf(BtRadiInfo);
  hRadio := 0;

  hFind := BluetoothFindFirstRadio(@BtRadiFindParam, hRadio);

  try
    if hFind <> 0 then
    begin
      repeat
        if BluetoothGetRadioInfo(hRadio, BtRadiInfo) = 0 then
        begin
          New(pInfo);
          ZeroMemory(pInfo, SizeOf(TBtDevEnt));

          pInfo.dInfo := BtRadiInfo;
          pInfo.sAddress := Format('%.2x:%.2x:%.2x:%.2x:%.2x:%.2x',
                [BtRadiInfo.Address.rgBytes[5], BtRadiInfo.Address.rgBytes[4],
                 BtRadiInfo.Address.rgBytes[3], BtRadiInfo.Address.rgBytes[2],
                 BtRadiInfo.Address.rgBytes[1], BtRadiInfo.Address.rgBytes[0]]);

          BTRadioList_.Add(pInfo);
        end;
      until (hFind <> 0) and not BluetoothFindNextRadio(hFind, hRadio);
      Result := true;
    end;
  finally
    if hFind <> 0 then
    begin
      BluetoothFindRadioClose(hFind);
    end;
  end;
end;

{ TThdBtDevNotify }

Constructor TThdBtDevNotify.Create(bSync: Boolean = false);
begin
  Inherited Create;
  bSync_ := bSync;
  DcBTDevice_ := TDictionary<String,PBtDevEnt>.Create;
  DcBTDevice_.OnValueNotify := OnBTDeviceNotify;
  BTDevice_ := TBluetoothDevice.Create;
  bPreventBtDevs_ := false;
end;

Destructor TThdBtDevNotify.Destroy;
begin
  Inherited;
  FreeAndNil(BTDevice_);
  FreeAndNil(DcBTDevice_);
end;

procedure TThdBtDevNotify.OnBTDeviceNotify(Sender: TObject; const Item: PBtDevEnt;
                                          Action: TCollectionNotification);
begin
  case Action of
    cnAdded: ;
    cnRemoved: Dispose(Item);
    cnExtracted: ;
  end;
end;

procedure TThdBtDevNotify.ResetBTDevice;
begin
  Lock;
  try
    DcBTDevice_.Clear;
  finally
    Unlock;
  end;
end;

procedure TThdBtDevNotify.ProcessBTDeviceNotify;
var
  bPrevent: Boolean;
begin
  if Assigned(evChangeBTDevice_) then
  begin
    evChangeBTDevice_(pBTEntry_, csBT_, bPrevent);
    if bPrevent then
      bPreventBtDevs_ := true;
  end;
end;

function TThdBtDevNotify.GetBTDeviceState(aEnt: PBtDevEnt): TBTChangeStates;
var
  pEntry: PBtDevEnt;
begin
  Result := [];

  Lock;
  try
    if DcBTDevice_.ContainsKey(aEnt.sAddress) then
      pEntry := DcBTDevice_[aEnt.sAddress]
    else
      pEntry := nil;
  finally
    Unlock;
  end;

  if pEntry <> nil then
  begin
    if pEntry.dInfo.fConnected <> aEnt.dInfo.fConnected then
      Include(Result, csConnected);
    if pEntry.dInfo.fRemembered <> aEnt.dInfo.fRemembered then
      Include(Result, csRemembered);
    if pEntry.dInfo.fAuthenticated <> aEnt.dInfo.fAuthenticated then
      Include(Result, csAuthenticated);
  // LastSeen은 BluetoothFindFirstDevice 이거 돌릴때마다 변경 12_1102 17:37 kku 
//    if not SameDateTime(pEntry.dtLastSeen, aEnt.dtLastSeen)  then
//      Include(Result, csLastSeen);
    if not SameDateTime(pEntry.dtLastUsed, aEnt.dtLastUsed) then
      Include(Result, csLastUsed);
    pEntry^ := aEnt^;
  end else begin
    New(pEntry);
    pEntry^ := aEnt^;
    Lock;
    try
      DcBTDevice_.Add(pEntry.sAddress, pEntry);
    finally
      Unlock;
    end;
    Include(Result, csDetection);
//    if pEntry.dInfo.fConnected then
//      Include(Result, csConnected);
//    if pEntry.dInfo.fRemembered then
//      Include(Result, csRemembered);
//    if pEntry.dInfo.fAuthenticated then
//      Include(Result, csAuthenticated);
  end;
end;

procedure TThdBtDevNotify.Execute;
var
  i: Integer;
  dwPvTick: DWORD;
begin
  dwPvTick := 0;
  while not Terminated and not GetWorkStop do
  begin
    if BTDevice_.RefreshBTDevice(@bWorkStop_) then
      for i := 0 to BTDevice_.Count - 1 do
      begin
        if bWorkStop_ then
          break;

        pBTEntry_ := BTDevice_[i];

        csBT_ := GetBTDeviceState(pBTEntry_);
        if csBT_ <> [] then
        begin
          if bSync_ then
            Synchronize(ProcessBTDeviceNotify)
          else
            ProcessBTDeviceNotify;
        end;
      end
    else Sleep(1000);

    if bPreventBtDevs_ then
    begin
      bPreventBtDevs_ := false;
      SetBtDevsEnable(false);
      dwPvTick := GetTickCount;
    end;

    if (dwPvTick <> 0) and ((GetTickCount - dwPvTick) >= 4000) then
    begin
      dwPvTick := 0;
      SetBtDevsEnable(true);
    end;

    Sleep(1000);
  end;
end;

// 디바이스 타입 구하기 = 개노가닥 2010-12-16 kku 
// 참고 : http://slexy.org/view/s2vpOPLzA7
procedure BtDevTypeToStr(dwClassOfDevice: DWORD; var sMajor, sMinor: String);
var
  ucMajor,
  ucMinor: BYTE;
begin
  ucMajor := BYTE((dwClassOfDevice and $0000FF00) shr 8);
  ucMinor := BYTE(dwClassOfDevice and $000000FF);

  case ucMajor of
    BT_DEVICE_CLASS_MISC           : sMajor := 'Misc';
    BT_DEVICE_CLASS_COMPUTER       : sMajor := 'Computer';
    BT_DEVICE_CLASS_PHONE          : sMajor := 'Phone';
    BT_DEVICE_CLASS_LANACCESSPOINT : sMajor := 'LanAccessPoint';
    BT_DEVICE_CLASS_AV             : sMajor := 'Audio/Video';
    BT_DEVICE_CLASS_PERIPHERAL     : sMajor := 'Peripheral';
    BT_DEVICE_CLASS_IMAGING        : sMajor := 'Imaging';
    BT_DEVICE_CLASS_UNCLASSIFIED   : sMajor := 'Unclassified';
  end;

  case (ucMajor and $0F) of
    0 : sMinor := 'None';
    1 :
      case ((ucMinor and $0F) shr 2) of
        0 : sMinor := 'Uncategorised';
        1 : sMinor := 'Desktop';
        2 : sMinor := 'Server';
        3 : sMinor := 'Laptop';
        4 : sMinor := 'Handheld';
        5 : sMinor := 'Palm';
        6 : sMinor := 'Wearable';
      end;
    2 :
      case ((ucMinor and $0F) shr 2) of
        0 : sMinor := 'Uncategorised';
        1 : sMinor := 'Mobile';
        2 : sMinor := 'Cordless';
        3 : sMinor := 'Smart phone';
        4 : sMinor := 'Wired modem or voice gateway';
        5 : sMinor := 'Common ISDN access';
        6 : sMinor := 'Sim card reader';
      end;
    3 :
      begin
        if ((ucMinor and $0F) shr 2) = 0 then
          sMinor := 'Uncategorised'
        else
          case (((ucMinor and $0F) shr 2) div 8) of
            0 : sMinor := 'Fully available';
            1 : sMinor := '1-17%% available';
            2 : sMinor := '17-33%% utilised';
            3 : sMinor := '33-50%% utilised';
            4 : sMinor := '50-67%% utilised';
            5 : sMinor := '67-83%% utilised';
            6 : sMinor := '83-99%% utilised';
            7 : sMinor := 'No service available';
          end;
      end;
    4 :
      case ((ucMinor and $0F) shr 2) of
        0 : sMinor := 'Uncategorised';
        1 : sMinor := 'Device conforms to the Headset profile';
        2 : sMinor := 'Hands-free';
        3 : sMinor := 'Reserved';
        4 : sMinor := 'Microphone';
        5 : sMinor := 'Loudspeaker';
        6 : sMinor := 'Headphones';
        7 : sMinor := 'Portable audio';
        8 : sMinor := 'Car audio';
        9 : sMinor := 'Set-top box';
       10 : sMinor := 'HiFi audio device';
       11 : sMinor := 'VCR';
       12 : sMinor := 'Video camera';
       13 : sMinor := 'Camcorder';
       14 : sMinor := 'Video monitor';
       15 : sMinor := 'Video display and loudspeaker';
       16 : sMinor := 'Video conferencing';
       17 : sMinor := 'Reserved';
       18 : sMinor := 'Gaming/toy';
      end;
    5 :
      begin
        case (((ucMinor and $0F) shr 2) and 48) of
          16 : sMinor := 'Keyboard';
          32 : sMinor := 'Pointing device';
          48 :
            begin
              sMinor := 'Combo keyboard/pointing device';
              sMinor := sMinor + ' - ';

              case (((ucMinor and $0F) shr 2) and 15) of
                1 : sMinor := sMinor + 'Joystick';
                2 : sMinor := sMinor + 'Gamepad';
                3 : sMinor := sMinor + 'Remote control';
                4 : sMinor := sMinor + 'Sensing device';
                5 : sMinor := sMinor + 'Digitiser tablet';
                6 : sMinor := sMinor + 'Card reader';
                else
                  sMinor := sMinor + 'Reserved';
              end;
            end;
        end;
      end;
    6 :
      if (((ucMinor and $0F) shr 2) and 4) <> 0 then
        sMinor := 'Display'
      else if (((ucMinor and $0F) shr 2) and 8) <> 0 then
        sMinor := 'Camera'
      else if (((ucMinor and $0F) shr 2) and 16) <> 0 then
        sMinor := 'Scanner'
      else if (((ucMinor and $0F) shr 2) and 32) <> 0 then
        sMinor := 'Printer';
    7 :
      case ((ucMinor and $0F) shr 2) of
        1 : sMinor := 'Wrist watch';
        2 : sMinor := 'Pager';
        3 : sMinor := 'Jacket';
        4 : sMinor := 'Helmet';
        5 : sMinor := 'Glasses';
      end;
    8 :
      case ((ucMinor and $0F) shr 2) of
        1 : sMinor := 'Robot';
        2 : sMinor := 'Vehicle';
        3 : sMinor := 'Doll/action figure';
        4 : sMinor := 'Controller';
        5 : sMinor := 'Game';
      end;
  end;

  if sMinor = '' then
    sMinor := 'None';
end;

end.
