feat: initialized Bluetooth module (#277)

This commit is contained in:
Serhiy Mytrovtsiy
2021-07-08 23:03:02 +02:00
parent 178b713353
commit 22386a4ae1
10 changed files with 848 additions and 46 deletions

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.</string>
</dict>
</plist>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Name</key>
<string>Bluetooth</string>
<key>State</key>
<true/>
<key>Widgets</key>
<dict>
<key>label</key>
<dict>
<key>Default</key>
<false/>
<key>Title</key>
<string>BLE</string>
<key>Order</key>
<integer>0</integer>
</dict>
<key>mini</key>
<dict>
<key>Title</key>
<string>BLE</string>
<key>Default</key>
<false/>
<key>Preview</key>
<dict>
<key>Title</key>
<string>BLE</string>
<key>Value</key>
<string>0.98</string>
</dict>
<key>Unsupported colors</key>
<array>
<string>pressure</string>
</array>
<key>Order</key>
<integer>1</integer>
</dict>
<key>battery</key>
<dict>
<key>Default</key>
<true/>
<key>Order</key>
<integer>2</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,116 @@
//
// main.swift
// Bluetooth
//
// Created by Serhiy Mytrovtsiy on 08/06/2021.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
import Kit
import CoreBluetooth
public enum BLEType: String {
case iPhone
case airPods
case unknown
}
public struct BLEDevice {
let uuid: UUID
let name: String
let type: BLEType
var RSSI: Int?
var batteryLevel: [KeyValue_t]
var isConnected: Bool
var isPaired: Bool
var isInitialized: Bool
var peripheral: CBPeripheral?
}
public class Bluetooth: Module {
private var devicesReader: DevicesReader? = nil
private let popupView: Popup = Popup()
private let settingsView: Settings
private var selectedBattery: String = ""
public init() {
self.settingsView = Settings("Bluetooth")
super.init(
popup: self.popupView,
settings: self.settingsView
)
guard self.available else { return }
self.devicesReader = DevicesReader()
self.selectedBattery = Store.shared.string(key: "\(self.config.name)_battery", defaultValue: self.selectedBattery)
self.settingsView.selectedBatteryHandler = { [unowned self] value in
self.selectedBattery = value
}
self.devicesReader?.callbackHandler = { [unowned self] value in
self.batteryCallback(value)
}
self.devicesReader?.readyCallback = { [unowned self] in
self.readyHandler()
}
if let reader = self.devicesReader {
self.addReader(reader)
}
}
private func batteryCallback(_ raw: [BLEDevice]?) {
guard let value = raw else {
return
}
let active = value.filter{ $0.isPaired && ($0.isConnected || !$0.batteryLevel.isEmpty) }
DispatchQueue.main.async(execute: {
self.popupView.batteryCallback(active)
})
self.settingsView.setList(active)
var battery = active.first?.batteryLevel.first
if self.selectedBattery != "" {
let pair = self.selectedBattery.split(separator: "@")
guard let device = value.first(where: { $0.name == pair.first! }) else {
error("cannot find selected battery: \(self.selectedBattery)")
return
}
if pair.count == 1 {
battery = device.batteryLevel.first
} else if pair.count == 2 {
battery = device.batteryLevel.first{ $0.key == pair.last! }
}
}
self.widgets.filter{ $0.isActive }.forEach { (w: Widget) in
switch w.item {
case let widget as Mini:
guard let percentage = Double(battery?.value ?? "0") else {
return
}
widget.setValue(percentage/100)
case let widget as BatterykWidget:
var percentage: Double? = nil
if let value = battery?.value {
percentage = (Double(value) ?? 0) / 100
}
widget.setValue(percentage: percentage)
default: break
}
}
}
}

View File

@@ -0,0 +1,111 @@
//
// popup.swift
// Bluetooth
//
// Created by Serhiy Mytrovtsiy on 22/06/2021.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
import Kit
internal class Popup: NSStackView, Popup_p {
public var sizeCallback: ((NSSize) -> Void)? = nil
private var list: [UUID: BLEView] = [:]
public init() {
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.orientation = .vertical
self.spacing = Constants.Popup.margins
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
internal func batteryCallback(_ list: [BLEDevice]) {
let views = self.subviews.filter{ $0 is BLEView }.map{ $0 as! BLEView }
list.reversed().forEach { (ble: BLEDevice) in
if let view = views.first(where: { $0.uuid == ble.uuid }) {
view.update(ble.batteryLevel)
} else {
self.addArrangedSubview(BLEView(
width: self.frame.width,
uuid: ble.uuid,
name: ble.name,
batteryLevel: ble.batteryLevel
))
}
}
let h = self.arrangedSubviews.map({ $0.bounds.height + self.spacing }).reduce(0, +) - self.spacing
if h > 0 && self.frame.size.height != h {
self.setFrameSize(NSSize(width: self.frame.width, height: h))
self.sizeCallback?(self.frame.size)
}
}
}
internal class BLEView: NSStackView {
public var uuid: UUID
open override var intrinsicContentSize: CGSize {
return CGSize(width: self.bounds.width, height: self.bounds.height)
}
public init(width: CGFloat, uuid: UUID, name: String, batteryLevel: [KeyValue_t]) {
self.uuid = uuid
super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30))
self.orientation = .horizontal
self.alignment = .centerY
self.spacing = 0
self.wantsLayer = true
self.layer?.cornerRadius = 2
let nameView: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: 0, height: 16))
nameView.font = NSFont.systemFont(ofSize: 13, weight: .light)
nameView.stringValue = name
self.addArrangedSubview(nameView)
self.addArrangedSubview(NSView())
batteryLevel.forEach { (pair: KeyValue_t) in
self.addLevel(pair)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayer() {
self.layer?.backgroundColor = isDarkMode ? NSColor(hexString: "#111111", alpha: 0.25).cgColor : NSColor(hexString: "#f5f5f5", alpha: 1).cgColor
}
public func update(_ batteryLevel: [KeyValue_t]) {
batteryLevel.forEach { (pair: KeyValue_t) in
if let view = self.subviews.first(where: { $0.identifier?.rawValue == pair.key }) as? NSTextField {
view.stringValue = "\(pair.value)%"
} else {
self.addLevel(pair)
}
}
}
private func addLevel(_ pair: KeyValue_t) {
let valueView: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: 0, height: 13))
valueView.identifier = NSUserInterfaceItemIdentifier(rawValue: pair.key)
valueView.font = NSFont.systemFont(ofSize: 12, weight: .regular)
valueView.stringValue = "\(pair.value)%"
valueView.toolTip = pair.key
self.addArrangedSubview(valueView)
}
}

View File

@@ -0,0 +1,179 @@
//
// readers.swift
// Bluetooth
//
// Created by Serhiy Mytrovtsiy on 08/06/2021.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
import Kit
import CoreBluetooth
import IOBluetooth
internal class DevicesReader: Reader<[BLEDevice]> {
private let ble: BluetoothDelegate = BluetoothDelegate()
init() {
super.init()
}
public override func read() {
self.ble.read()
self.callback(self.ble.devices)
}
}
class BluetoothDelegate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var manager: CBCentralManager!
private let cache = UserDefaults(suiteName: "/Library/Preferences/com.apple.Bluetooth")
private var peripherals: [CBPeripheral] = []
public var devices: [BLEDevice] = []
private var characteristicsDict: [UUID: CBCharacteristic] = [:]
private let batteryServiceUUID = CBUUID(string: "0x180F")
private let batteryCharacteristicsUUID = CBUUID(string: "0x2A19")
override init() {
super.init()
self.manager = CBCentralManager.init(delegate: self, queue: nil)
}
public func read() {
IOBluetoothDevice.pairedDevices().forEach { (d) in
guard let device = d as? IOBluetoothDevice,
let cache = self.findInCache(address: device.addressString) else {
return
}
let rssi = device.rawRSSI() == 127 ? nil : Int(device.rawRSSI())
if let idx = self.devices.firstIndex(where: { $0.uuid == cache.uuid }) {
self.devices[idx].isConnected = device.isConnected()
self.devices[idx].isPaired = device.isPaired()
self.devices[idx].RSSI = rssi
} else {
self.devices.append(BLEDevice(
uuid: cache.uuid,
name: device.nameOrAddress,
type: .unknown,
RSSI: rssi,
batteryLevel: cache.batteryLevel,
isConnected: device.isConnected(),
isPaired: device.isPaired(),
isInitialized: false
))
}
}
}
private func findInCache(address: String) -> (uuid: UUID, batteryLevel: [KeyValue_t])? {
guard let plist = self.cache,
let deviceCache = plist.object(forKey: "DeviceCache") as? [String: [String: Any]],
let coreCache = plist.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else {
return nil
}
guard let uuid = coreCache.compactMap({ (key, dict) -> UUID? in
guard let field = dict.first(where: { $0.key == "DeviceAddress" }),
let value = field.value as? String,
value == address else {
return nil
}
return UUID(uuidString: key)
}).first else {
return nil
}
var batteryLevel: [KeyValue_t] = []
if let d = deviceCache.first(where: { $0.key == address }) {
d.value.forEach { (key, value) in
guard let value = value as? Int, key == "BatteryPercentCase" || key == "BatteryPercentLeft" || key == "BatteryPercentRight" else {
return
}
batteryLevel.append(KeyValue_t(key: key, value: "\(value)"))
}
}
return (uuid, batteryLevel)
}
// MARK: - CBCentralManagerDelegate
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOff {
self.manager.stopScan()
} else if central.state == .poweredOn {
self.manager.scanForPeripherals(withServices: nil, options: nil)
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard let idx = self.devices.firstIndex(where: { $0.uuid == peripheral.identifier }) else {
return
}
if self.devices[idx].RSSI == nil {
self.devices[idx].RSSI = Int(truncating: RSSI)
}
if self.devices[idx].peripheral == nil {
self.devices[idx].peripheral = peripheral
}
if peripheral.state == .disconnected {
central.connect(peripheral, options: nil)
} else if peripheral.state == .connected && !self.devices[idx].isInitialized {
peripheral.delegate = self
peripheral.discoverServices([batteryServiceUUID])
self.devices[idx].isInitialized = true
}
}
// MARK: - CBPeripheralDelegate
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil else {
print("didDiscoverServices: ", error!)
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == self.batteryServiceUUID }) else {
print("battery service not found, skipping")
return
}
peripheral.discoverCharacteristics([self.batteryCharacteristicsUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard error == nil else {
print("didDiscoverCharacteristicsFor: ", error!)
return
}
guard let batteryCharacteristics = service.characteristics?.first(where: { $0.uuid == self.batteryCharacteristicsUUID }) else {
print("characteristics not found")
return
}
self.characteristicsDict[peripheral.identifier] = batteryCharacteristics
peripheral.readValue(for: batteryCharacteristics)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil else {
print("didUpdateValueFor: ", error!)
return
}
if let batteryLevel = characteristic.value?[0], let idx = self.devices.firstIndex(where: { $0.uuid == peripheral.identifier }) {
self.devices[idx].batteryLevel = [KeyValue_t(key: "battery", value: "\(batteryLevel)")]
}
}
}

View File

@@ -0,0 +1,112 @@
//
// settings.swift
// Bluetooth
//
// Created by Serhiy Mytrovtsiy on 07/07/2021.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
import Kit
internal class Settings: NSStackView, Settings_v {
public var callback: (() -> Void) = {}
public var selectedBatteryHandler: (String) -> Void = {_ in }
private let title: String
private var selectedBattery: String
private var button: NSPopUpButton?
public init(_ title: String) {
self.title = title
self.selectedBattery = Store.shared.string(key: "\(self.title)_battery", defaultValue: "")
super.init(frame: NSRect(
x: 0,
y: 0,
width: Constants.Settings.width - (Constants.Settings.margin*2),
height: 0
))
self.orientation = .vertical
self.distribution = .gravityAreas
self.edgeInsets = NSEdgeInsets(
top: Constants.Settings.margin,
left: Constants.Settings.margin,
bottom: Constants.Settings.margin,
right: Constants.Settings.margin
)
self.spacing = Constants.Settings.margin
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
internal func load(widgets: [widget_t]) {
self.subviews.forEach{ $0.removeFromSuperview() }
self.addArrangedSubview(self.deviceSelector())
let h = self.arrangedSubviews.map({ $0.bounds.height + self.spacing }).reduce(0, +) - self.spacing + self.edgeInsets.top + self.edgeInsets.bottom
if self.frame.size.height != h {
self.setFrameSize(NSSize(width: self.bounds.width, height: h))
}
}
private func deviceSelector() -> NSView {
let view: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.frame.width - Constants.Settings.margin*2, height: Constants.Settings.row))
let rowTitle: NSTextField = LabelField(
frame: NSRect(x: 0, y: (view.frame.height - 16)/2, width: view.frame.width - 52, height: 17),
localizedString("Battery to show")
)
rowTitle.font = NSFont.systemFont(ofSize: 13, weight: .light)
rowTitle.textColor = .textColor
self.button = NSPopUpButton(frame: NSRect(x: view.frame.width - 140, y: -1, width: 140, height: 30))
self.button!.target = self
self.button?.action = #selector(self.handleSelection)
view.addSubview(rowTitle)
view.addSubview(self.button!)
return view
}
internal func setList(_ list: [BLEDevice]) {
var batteries: [String] = []
list.forEach { (d: BLEDevice) in
if d.batteryLevel.count == 1 {
batteries.append(d.name)
} else {
d.batteryLevel.forEach { (pair: KeyValue_t) in
batteries.append("\(d.name)@\(pair.key)")
}
}
}
DispatchQueue.main.async(execute: {
if self.button?.itemTitles.count != batteries.count {
self.button?.removeAllItems()
}
if batteries != self.button?.itemTitles {
self.button?.addItems(withTitles: batteries.map{ $0.replacingOccurrences(of: "@", with: " - ")})
if self.selectedBattery != "" {
self.button?.selectItem(withTitle: self.selectedBattery.replacingOccurrences(of: "@", with: " - "))
}
}
})
}
@objc private func handleSelection(_ sender: NSPopUpButton) {
guard let item = sender.selectedItem else { return }
self.selectedBattery = item.title.replacingOccurrences(of: " - ", with: "@")
Store.shared.set(key: "\(self.title)_battery", value: self.selectedBattery)
self.selectedBatteryHandler(self.selectedBattery)
}
}