MacFeeling Blog

マックな感じ …

SwitchbotのプラグミニをBluetoothで制御する

公開 : | 0件のコメント

Mac » UR12 » アクティブスピーカーな構成なのですが、Macのシステム終了時に、先にアクティブスピーカーの電源を落としておかないと、ボンという大きい音がなってしまいます。精神的にもスピーカー的にもよろしくないので、何とかならないかと思っていたところ、以前購入していたSwitchbotのプラグミニがBLE(Bluetooth Low Energy)でコントロールできるとのことで、Core Bluetoothを使ってアプリを作成することに。

SwitchBot スマートプラグ プラグミニ HomeKit対応 2個入りをAmazonで見る

まずは公式サイトにてAPIを確認。
電源オンは0x570f50010180
電源オフは0x570f50010100をそれぞれ送れば良いらしい。

  • Turn On Plug Mini REQ 0x570f50010180 RESP 0x0180
  • Turn Off Plug Mini REQ 0x570f50010100 RESP 0x0100

Core Bluetoothおよびプロセつについては、ググって以下を使わせていただきました。
SwiftでBluetooth通信をマスター!たったの10ステップで完璧に実装 – Japanシーモア
[macos]管理者でプロセス実行 #Swift - Qiita

最初は1つのアプリで電源オンとオフを実現しようとしたけれど、システム終了時の処理がどうやってもうまくいかず、2つのアプリに分けることに。

まずは、お使いのプラグミニのUUIDを調べてください。

プラグミニのUUIDの検索方法
私は、LightBlueというアプリを使いました。

LightBlue®
カテゴリ: Utilities
価格: 無料

プラグミニの電源を入れたら、アプリを起動してください。
検索を始めます。
検索結果にWoPlugという名前が表示されたら、それがプラグミニのものになります。
(プラグミニを複数設置している場合は、設定するもの以外を外した方が確実です。)

私の場合は、最初なかなか表示されず、プラグミニの設置位置を色々変えたりしていたら、ようやく表示されました。

右のConnectをクリックしてください。
接続が完了すると、以下のように表示されます。
このUUID部分がお使いのプラグミニのUUIDになります。

アプリを作成していきます。
電源オンの方。Macの起動時に、プラグミニの電源を入れます。

Xcodeを起動したら、新規プロジェクトを作成。
新規プロジェクトはSwift、SwiftUIで、プロジェクト名:PlugminiPowerOnApp

新規ファイルを作成します。
ファイル名;BluetoothService.swiftを作成

以下をお使いのプラグミニのUUID置き換えてください。
var serviceUUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" //プラグミニのUUID

//
//  BluetoothService.swift
// 

import Foundation
import CoreBluetooth
import AppKit

class BluetoothService: NSObject, ObservableObject {
    static var shared = BluetoothService()

    @Published var isSearching: Bool = false

    var centralManager: CBCentralManager!

    var serviceUUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" //プラグミニのUUID
   

    // 接続先の機器
    private var connectPeripheral: CBPeripheral? = nil
    override init() {
        super.init()
        self.centralManager = CBCentralManager()
    }

    // MARK: - Methods
    /// スキャン開始
    func startBluetoothScan() {
        print("スキャン開始")
        self.centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    /// スキャン停止
    func stopBluetoothScan() {
        print("スキャン停止")
        self.centralManager.stopScan()
        self.isSearching = false
    }
}

// MARK: CBCentralManagerDelegate

extension BluetoothService: CBCentralManagerDelegate, CBPeripheralDelegate {

    // Bluetoothのステータスを取得する(CBCentralManagerの状態が変わる度に呼び出される)
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOff:
            print("Bluetooth PoweredOff")
            break
        case .poweredOn:
            print("Bluetooth poweredOn")
            self.centralManager.scanForPeripherals(withServices: nil, options: nil)
            self.isSearching = true
            break
        case .resetting:
            print("Bluetooth resetting")
            break
        case .unauthorized:
            print("Bluetooth unauthorized")
            break
        case .unknown:
            print("Bluetooth unknown")
            break
        case .unsupported:
            print("Bluetooth unsupported")
            break
        @unknown default:
            print("unknown")
        }
    }

    // スキャン結果取得
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        // 対象機器のみ保持する
        print("Name=\(String(describing: peripheral.name))")
        print("services=\(String(describing: peripheral.services))")
        print("UUID=\(peripheral.identifier.uuidString)")
        if peripheral.identifier.uuidString == serviceUUID {
            // 対象機器のみ保持する
            self.connectPeripheral = peripheral
            // 機器に接続
            print("機器に接続:\(String(describing: peripheral.name))")
            self.centralManager.connect(peripheral, options: nil)
          
            //self.stopBluetoothScan() //ここで止めるとエラーになる
        }
    }

    // 接続成功時
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("接続成功")
        self.connectPeripheral = peripheral
        self.connectPeripheral?.delegate = self
        //ペリフェラルのサービスを探索
        if let peripheral = self.connectPeripheral {
            //
            peripheral.discoverServices(nil)
        }
        // スキャン停止処理
        self.stopBluetoothScan()
    }
    
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }
        for service in services {
            print("Peripheralのサービスが見つかったのでキャラクタリスティックを検索")
            // キャラクタリスティックを検索
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        for characteristic in service.characteristics! {
            print("didDiscoverCharacteristicsFor")
            if characteristic.properties.contains(.write) {
                print("書き込み可能なキャラクタリスティックが見つかった")
                //書き込むデータを作成
                //電源オン
                print("電源オン")
                let bytes : [UInt8] = [ 0x57, 0x0F, 0x50, 0x01, 0x01, 0x80]
                let data = Data(bytes)
                // キャラクタリスティックからデータを書き込む
                peripheral.writeValue(data, for: characteristic, type: .withResponse)

                //ペリフェラルデバイスとの接続 ここでやると正常に書き込めない
                //self.disconnectPeripheral(central: self.centralManager, peripheral: peripheral)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        print("didWriteValueFor")
        //ペリフェラルデバイスとの接続を切る
        self.disconnectPeripheral(central: self.centralManager, peripheral: peripheral)
        
            //終了時ならば自分を終了する
            print("正常に書き込まれたので自分自身を終了")
            NSApplication.shared.terminate(self)
    }
    
    
    // 接続失敗時
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        print("接続失敗:\(String(describing: error))")
        
        // スキャン停止処理
        self.stopBluetoothScan()
    }
    
    // ペリフェラルデバイスとの接続を切断する関数
    func disconnectPeripheral(central: CBCentralManager, peripheral: CBPeripheral) {
        central.cancelPeripheralConnection(peripheral)
    }

    // 接続切断時
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        //print("接続切断:\(String(describing: error))")
        if let error = error {
            // エラー情報を出力
            print("切断時にエラーが発生しました: \(error.localizedDescription)")
        } else {
            // 通常の切断処理
            print("正常に切断されました。")
        }
    }
}

これを、PlugminiPowerOnApp.swift側では、以下のように記述
(内容は、PlugminiPowerOnApp、PlugminiPowerOffApp共通です。)

import SwiftUI

@main
struct PlugminiPowerOnApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowResizability(.contentSize)
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    @ObservedObject private var bluetoothService = BluetoothService.shared
    
    // MARK: - AppDelegate Lifecycle
    func applicationWillFinishLaunching(_ aNotification: Notification) {
        print("applicationWillFinishLaunching")
        //起動時の処理
       
    }
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        print("applicationDidFinishLaunching")
 
        //プラグインミニの電源を入れる(PlugminiPowerOffの場合は切る)
        self.bluetoothService.isSearching = true

        self.bluetoothService.startBluetoothScan()
    }
    
    // アプリケーション終了直前通知 applicationShouldTerminateAfterLastWindowClosedの後、applicationWillTerminate よりも先
    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        print("applicationShouldTerminate")
        
        return NSApplication.TerminateReply.terminateNow
        
    }
    
    func applicationWillTerminate(_ notification: Notification) {
        print("applicationWillTerminate")
 
    }
    
}

TARGETのInfo.plistに以下を追加。Stringに記述する説明文は任意のものを
Privacy - Bluetooth Always Usage Description
Privacy - Bluetooth Peripheral Usage Description

Sandboxを有効にしているのなら、Signing & Capabilities » App Sandbox »HardwareにてBluetoothにチェックを入れること。

出来上がったアプリは、Sleepyhead.appに登録して、少し遅らせて起動して使っています。


次は電源オフの方。プラグミニの電源を切って、システムを終了します。

Xcodeを起動したら、新規プロジェクトを作成。
新規プロジェクトはSwift、SwiftUIで、プロジェクト名:PlugminiPowerOffApp
SandboxをNOに(電源オフ後にシステムを終了させるため)

以下の2箇所をそれそれ置き換えてください。
var serviceUUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" //プラグミニのUUID
let password = "システム管理者パスワード"

//
//  BluetoothService.swift
//  PlugminiPowerOffApp
//

import Foundation
import CoreBluetooth
import AppKit

class BluetoothService: NSObject, ObservableObject {
    static var shared = BluetoothService()

    @Published var isSearching: Bool = false

    var centralManager: CBCentralManager!
    var serviceUUID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" //プラグミニのUUID
    
    /// 接続先の機器
    private var connectPeripheral: CBPeripheral? = nil

    override init() {
        super.init()
        self.centralManager = CBCentralManager()
    }

    // MARK: - Public Methods

    /// スキャン開始
    func startBluetoothScan() {
        print("スキャン開始")
        self.centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    /// スキャン停止
    func stopBluetoothScan() {
        print("スキャン停止")
        self.centralManager.stopScan()
        self.isSearching = false
    }
}

// MARK: CBCentralManagerDelegate

extension BluetoothService: CBCentralManagerDelegate, CBPeripheralDelegate {

    // Bluetoothのステータスを取得する(CBCentralManagerの状態が変わる度に呼び出される)
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOff:
            print("Bluetooth PoweredOff")
            break
        case .poweredOn:
            print("Bluetooth poweredOn")
            self.centralManager.scanForPeripherals(withServices: nil, options: nil)
            //self.centralManager.scanForPeripherals(withServices: serviceUUID, options: nil)
            self.isSearching = true
            break
        case .resetting:
            print("Bluetooth resetting")
            break
        case .unauthorized:
            print("Bluetooth unauthorized")
            break
        case .unknown:
            print("Bluetooth unknown")
            break
        case .unsupported:
            print("Bluetooth unsupported")
            break
        @unknown default:
            print("unknown")
        }
    }

    // スキャン結果取得
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        // 対象機器のみ保持する
        print("Name=\(String(describing: peripheral.name))")
        print("services=\(String(describing: peripheral.services))")
        print("UUID=\(peripheral.identifier.uuidString)")
        if peripheral.identifier.uuidString == serviceUUID {
            // 対象機器のみ保持する
            self.connectPeripheral = peripheral
            // 機器に接続
            print("機器に接続:\(String(describing: peripheral.name))")
            self.centralManager.connect(peripheral, options: nil)
            
            //self.stopBluetoothScan() //ここで止めるとエラーになる
        }
    }

    // 接続成功時
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("接続成功")
        self.connectPeripheral = peripheral
        self.connectPeripheral?.delegate = self
        //ペリフェラルのサービスを探索
        if let peripheral = self.connectPeripheral {
            peripheral.discoverServices(nil)
        }
        // スキャン停止処理
        self.stopBluetoothScan()
    }
    
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }
        for service in services {
            print("Peripheralのサービスが見つかったのでキャラクタリスティックを検索")
            // キャラクタリスティックを検索
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        for characteristic in service.characteristics! {
            print("didDiscoverCharacteristicsFor")
            if characteristic.properties.contains(.write) {
                print("書き込み可能なキャラクタリスティックが見つかった")
                
                //書き込むデータを作成
                //電源オフ
                //Turn Off Plug Mini REQ 0x570f50010100 RESP 0x0100
                print("電源オフ")
                let bytes : [UInt8] = [ 0x57, 0x0F, 0x50, 0x01, 0x01, 0x00]

                let data = Data(bytes)
                // キャラクタリスティックからデータを書き込む
                peripheral.writeValue(data, for: characteristic, type: .withResponse)

                //ペリフェラルデバイスとの接続 ここでやると正常に書き込めない
                //self.disconnectPeripheral(central: self.centralManager, peripheral: peripheral)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        print("didWriteValueFor")
        //ペリフェラルデバイスとの接続を切る
        self.disconnectPeripheral(central: self.centralManager, peripheral: peripheral)
    
        print("正常に書き込まれたのでシステム終了")
       //10秒後にシステム終了を実行
        self.shutdownProcess()
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            //3秒後に自分自身を終了
            NSApplication.shared.terminate(self)
        }
        
    }
    
    func shutdownProcess() {
        let process = Process()

        // -S: パスワードを端末ではなく標準入力から読み込む
        process.launchPath = "/usr/bin/sudo"
        ////sudo bash -c 'sleep 10 && shutdown -h now'
        let command = "sleep 10 && shutdown -h now"
        process.arguments = ["-S","bash", "-c", command]

        // パイプを設定
        let stdin = Pipe()
        let stdout = Pipe()
        process.standardOutput = stdout
        process.standardError = stdout
        process.standardInput = stdin

        // 実行
        process.launch()

        // パスワードの末尾には改行が必要
        let password = "システム管理者パスワード"
        let passwordWithNewline = password + "\n"

        // パスワードを標準入力へ書き込み
        stdin.fileHandleForWriting.write(passwordWithNewline.data(using: .utf8)!)
        try? stdin.fileHandleForWriting.close()
        
        // 終了まで待機(システム終了するので必要ない)
        /*
        process.waitUntilExit()

        // 標準出力を取得
        if let data = try? stdout.fileHandleForReading.readToEnd(),
           let output = String(data: data, encoding: .utf8) {
            print(output)
            
        }*/
    }
    
    // 接続失敗時
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        print("接続失敗:\(String(describing: error))")
        
        // スキャン停止処理
        self.stopBluetoothScan()
    }
    
    
    // ペリフェラルデバイスとの接続を切断する関数
    func disconnectPeripheral(central: CBCentralManager, peripheral: CBPeripheral) {
        central.cancelPeripheralConnection(peripheral)
    }

    // 接続切断時
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        //print("接続切断:\(String(describing: error))")
        if let error = error {
            // エラー情報を出力
            print("切断時にエラーが発生しました: \(error.localizedDescription)")
        } else {
            // 通常の切断処理
            print("正常に切断されました。")
        }
    }
}

システム終了は、アップルメニューから行うのではなく、このアプリを起動して行います。
私は、このアプリをショートカット.appで、アクションを作成して、メニューバーに固定して使っています。
名前は分かりやすいものに変更しています。

コメントを残す

必須欄は * がついています




日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)