├── .bundle └── config ├── .gitignore ├── bluetility_screenshot.png ├── fastlane ├── Pluginfile ├── Appfile ├── Gymfile └── Fastfile ├── Bluetility ├── Assets.xcassets │ ├── Contents.json │ ├── Logs.imageset │ │ ├── logs.pdf │ │ └── Contents.json │ ├── Refresh.imageset │ │ ├── refresh.pdf │ │ └── Contents.json │ ├── SortBySignal.imageset │ │ ├── low21.pdf │ │ └── Contents.json │ └── AppIcon.appiconset │ │ ├── bluetooth128.png │ │ ├── bluetooth16.png │ │ ├── bluetooth256.png │ │ ├── bluetooth32.png │ │ ├── bluetooth512.png │ │ ├── bluetooth64.png │ │ ├── bluetooth1024.png │ │ ├── bluetooth256-1.png │ │ ├── bluetooth32-1.png │ │ ├── bluetooth512-1.png │ │ └── Contents.json ├── Credits.html ├── NSTextField+Shake.swift ├── AppDelegate.swift ├── Info.plist ├── Swift+Extensions.swift ├── LogViewController.swift ├── BluetilityWindowController.swift ├── PasteboardBrowser.swift ├── Scanner.swift ├── BluetilityLogHandler.swift ├── Device.swift ├── ViewController.swift └── Base.lproj │ └── Main.storyboard ├── Gemfile ├── Bluetility.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcshareddata │ └── xcschemes │ │ ├── Bluetility Release.xcscheme │ │ └── Bluetility Debug.xcscheme └── project.pbxproj ├── Config ├── Release.xcconfig ├── Debug.xcconfig ├── Shared.xcconfig └── Warnings.xcconfig ├── LICENSE.md ├── README.md └── Gemfile.lock /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | fastlane/report.xml 3 | build 4 | vendor 5 | fastlane/README.md 6 | -------------------------------------------------------------------------------- /bluetility_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/bluetility_screenshot.png -------------------------------------------------------------------------------- /fastlane/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/Logs.imageset/logs.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/Logs.imageset/logs.pdf -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/Refresh.imageset/refresh.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/Refresh.imageset/refresh.pdf -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/SortBySignal.imageset/low21.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/SortBySignal.imageset/low21.pdf -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth128.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth16.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth256.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth32.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth512.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth64.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth1024.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth256-1.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth32-1.png -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jnross/Bluetility/HEAD/Bluetility/Assets.xcassets/AppIcon.appiconset/bluetooth512-1.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | 5 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 6 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 7 | -------------------------------------------------------------------------------- /Bluetility.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/Logs.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "logs.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/Refresh.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "refresh.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/SortBySignal.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "low21.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Bluetility.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Bluetility/Credits.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Icon made by Freepik from www.flaticon.com is licensed under CC BY 3.0
4 |
5 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.rossible.Bluetility") # The bundle identifier of your app 2 | apple_id("jnross@gmail.com") # Your Apple email address 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | 8 | team_id "PTE4TYXPW2" # Developer Portal Team ID 9 | itc_team_id "PTE4TYXPW2" # Developer Portal Team ID 10 | -------------------------------------------------------------------------------- /fastlane/Gymfile: -------------------------------------------------------------------------------- 1 | # For more information about this configuration visit 2 | # https://docs.fastlane.tools/actions/gym/#gymfile 3 | 4 | # In general, you can use the options available 5 | # fastlane gym --help 6 | 7 | # Remove the # in front of the line to enable the option 8 | 9 | # scheme("") 10 | 11 | sdk("macosx") 12 | 13 | clean(true) 14 | 15 | output_directory("./build") 16 | -------------------------------------------------------------------------------- /Bluetility.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-log", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-log.git", 7 | "state" : { 8 | "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", 9 | "version" : "1.5.2" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Config/Release.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Release.xcconfig 3 | // 4 | // Settings for Release build 5 | // 6 | 7 | #include "Shared.xcconfig" 8 | 9 | DEBUG_INFORMATION_FORMAT = dwarf-with-dsym 10 | ENABLE_NS_ASSERTIONS = NO 11 | MTL_ENABLE_DEBUG_INFO = NO 12 | SWIFT_COMPILATION_MODE = wholemodule 13 | 14 | CODE_SIGN_IDENTITY = Developer ID Application 15 | CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO 16 | DEVELOPMENT_TEAM = PTE4TYXPW2 17 | OTHER_CODE_SIGN_FLAGS = --timestamp 18 | -------------------------------------------------------------------------------- /Config/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Debug.xcconfig 3 | // 4 | // Settings for Debug build 5 | // 6 | 7 | #include "Shared.xcconfig" 8 | 9 | DEBUG_INFORMATION_FORMAT = dwarf 10 | ENABLE_TESTABILITY = YES 11 | GCC_DYNAMIC_NO_PIC = NO 12 | GCC_OPTIMIZATION_LEVEL = 0 13 | GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited) 14 | MTL_ENABLE_DEBUG_INFO = YES 15 | ONLY_ACTIVE_ARCH = YES 16 | SWIFT_OPTIMIZATION_LEVEL = -Onone 17 | 18 | DEVELOPMENT_TEAM = 19 | CODE_SIGN_IDENTITY = - 20 | -------------------------------------------------------------------------------- /Config/Shared.xcconfig: -------------------------------------------------------------------------------- 1 | 2 | #include "Warnings.xcconfig" 3 | 4 | // Project 5 | MACOSX_DEPLOYMENT_TARGET = 10.13 6 | SDKROOT = macosx 7 | SWIFT_VERSION = 5.0 8 | VERSIONING_SYSTEM = apple-generic 9 | COPY_PHASE_STRIP = NO 10 | 11 | // Target 12 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon 13 | CODE_SIGN_STYLE = Manual 14 | COMBINE_HIDPI_IMAGES = YES 15 | ENABLE_HARDENED_RUNTIME = YES 16 | INFOPLIST_FILE = Bluetility/Info.plist 17 | LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks 18 | PRODUCT_BUNDLE_IDENTIFIER = com.rossible.Bluetility 19 | PRODUCT_NAME = $(TARGET_NAME) 20 | 21 | // Version 22 | CURRENT_PROJECT_VERSION = 9 23 | MARKETING_VERSION = 1.5.1 24 | -------------------------------------------------------------------------------- /Bluetility/NSTextField+Shake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSTextField+Shake.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 9/28/19. 6 | // Copyright © 2019 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | extension NSTextField { 12 | func shake() { 13 | let animation = CABasicAnimation(keyPath: "position") 14 | animation.duration = 0.05 15 | animation.repeatCount = 5 16 | animation.autoreverses = true 17 | animation.fromValue = CGPoint(x: self.frame.origin.x - 4.0, y: self.frame.origin.y) 18 | animation.toValue = CGPoint(x: self.frame.origin.x + 4.0, y: self.frame.origin.y) 19 | layer?.add(animation, forKey: "position") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Bluetility/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 11/9/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Logging 11 | 12 | @NSApplicationMain 13 | class AppDelegate: NSObject, NSApplicationDelegate { 14 | 15 | 16 | 17 | func applicationDidFinishLaunching(_ aNotification: Notification) { 18 | // Insert code here to initialize your application 19 | let defaults = ["NSToolTipAutoWrappingDisabled" : NSNumber(value: true)] 20 | UserDefaults.standard.register(defaults: defaults) 21 | } 22 | 23 | func applicationWillTerminate(_ aNotification: Notification) { 24 | // Insert code here to tear down your application 25 | } 26 | 27 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 28 | return true 29 | } 30 | 31 | func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 32 | true 33 | } 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016 Joseph Ross 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:mac) 17 | 18 | platform :mac do 19 | desc "Increments the build number, builds, and notarizes the Release configuration of the app." 20 | lane :build_release do 21 | ensure_git_status_clean 22 | gym( 23 | scheme: "Bluetility Release", 24 | skip_package_pkg: true, 25 | ) 26 | app_path = "build/Bluetility.app" 27 | notarize( 28 | package: app_path, 29 | try_early_stapling: true, 30 | verbose: true, 31 | api_key_path: ENV['API_KEY_PATH'], 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /Bluetility/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSHumanReadableCopyright 28 | Copyright © 2015 Joseph Ross. All rights reserved. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | NSBluetoothAlwaysUsageDescription 34 | Please allow Bluetooth access to connect to devices. 35 | 36 | 37 | -------------------------------------------------------------------------------- /Bluetility/Swift+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Swift+Extensions.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 7/1/20. 6 | // Copyright © 2020 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: Data 12 | 13 | extension Data { 14 | var hexString: String { 15 | var hex:String = "" 16 | for byte in self { 17 | hex += String(format: "%02X", byte) 18 | } 19 | return hex 20 | } 21 | } 22 | 23 | extension Array { 24 | var hexString: String { 25 | var hex:String = "" 26 | for byte in self { 27 | hex += String(format: "%02X", byte) 28 | } 29 | return hex 30 | } 31 | } 32 | 33 | // MARK: Dictionary 34 | 35 | func += (left: inout Dictionary, right: Dictionary) { 36 | for (k, v) in right { 37 | left.updateValue(v, forKey: k) 38 | } 39 | } 40 | 41 | infix operator ??? : NilCoalescingPrecedence 42 | 43 | func ??? (left: Any?, right: String) -> String { 44 | if let left = left { 45 | return String(describing: left) 46 | } else { 47 | return right 48 | } 49 | } 50 | 51 | extension Collection { 52 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 53 | subscript (safe index: Index) -> Element? { 54 | return indices.contains(index) ? self[index] : nil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Config/Warnings.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Warnings.xcconfig 3 | // 4 | // Contains common warning and error settings for the whole project 5 | // 6 | 7 | ALWAYS_SEARCH_USER_PATHS = NO 8 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES 9 | CLANG_CXX_LANGUAGE_STANDARD = gnu++0x 10 | CLANG_CXX_LIBRARY = libc++ 11 | CLANG_ENABLE_MODULES = YES 12 | CLANG_ENABLE_OBJC_ARC = YES 13 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 14 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES 15 | CLANG_WARN_BOOL_CONVERSION = YES 16 | CLANG_WARN_COMMA = YES 17 | CLANG_WARN_CONSTANT_CONVERSION = YES 18 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES 19 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR 20 | CLANG_WARN_EMPTY_BODY = YES 21 | CLANG_WARN_ENUM_CONVERSION = YES 22 | CLANG_WARN_INFINITE_RECURSION = YES 23 | CLANG_WARN_INT_CONVERSION = YES 24 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES 25 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 26 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES 27 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR 28 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES 29 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES 30 | CLANG_WARN_STRICT_PROTOTYPES = YES 31 | CLANG_WARN_SUSPICIOUS_MOVE = YES 32 | CLANG_WARN_UNREACHABLE_CODE = YES 33 | ENABLE_STRICT_OBJC_MSGSEND = YES 34 | GCC_C_LANGUAGE_STANDARD = gnu99 35 | GCC_NO_COMMON_BLOCKS = YES 36 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES 37 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR 38 | GCC_WARN_UNDECLARED_SELECTOR = YES 39 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE 40 | GCC_WARN_UNUSED_FUNCTION = YES 41 | GCC_WARN_UNUSED_VARIABLE = YES 42 | -------------------------------------------------------------------------------- /Bluetility/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "bluetooth16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "bluetooth32-1.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "bluetooth32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "bluetooth64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "bluetooth128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "bluetooth256-1.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "bluetooth256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "bluetooth512-1.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "bluetooth512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "bluetooth1024.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bluetility 2 | 3 | Bluetility is a general-purpose Bluetooth Low-Energy utility for Mac OS X. It scans for advertising peripherals, provides a interface to browse a connected peripheral's services and characteristics, and allows characteristic values to be read, written, and subscribed. 4 | Bluetility Screenshot 5 | 6 | ## Installation 7 | 8 | ### Manual 9 | 1. Download the latest release: https://github.com/jnross/Bluetility/releases/latest/download/Bluetility.app.zip 10 | 2. Extract the downloaded archive. 11 | 3. Move Bluetility.app into your /Applications folder. Or don't! 12 | 4. Open Bluetility.app. 13 | 14 | ### Using Homebrew 15 | ``` 16 | brew install --cask bluetility 17 | ``` 18 | 19 | ## Features 20 | 21 | * Scan for nearby advertising peripherals 22 | * Sort peripherals by received signal strength 23 | * View advertising data via tooltip on Devices list 24 | * Browse services and characteristics of connected peripheral 25 | * Subscribe to characteristic notifications 26 | * Read/Write characteristic values 27 | * View log of characteristic read/writes, logs may be saved as CSV 28 | 29 | ## Motivation 30 | Bluetility is inspired by [LightBlue](https://itunes.apple.com/us/app/lightblue/id639944780?mt=12), a free bluetooth utility published by [Punch Through Design](https://punchthrough.com/). Bluetility was created to resolve issues in this tool, and add missing features: 31 | 32 | * Support copy/paste via Cmd+C and Cmd+V 33 | * Sort peripherals by received signal strength 34 | * View advertising data 35 | * Automatically reconnect to disconnected peripherals when needed 36 | 37 | Bluetility is published as open-source so that anyone can tweak or improve its functionality to meet their own needs. 38 | 39 | ## Release Build 40 | 41 | 1. Set notarization password as environment variable `FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD` 42 | 2. Execute command ```bundle exec fastlane mac build_release``` 43 | -------------------------------------------------------------------------------- /Bluetility/LogViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogViewController.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 12/29/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import CoreBluetooth 11 | 12 | class LogViewController: NSViewController, LogRecorderDelegate { 13 | 14 | var recorder: LogRecorder? = nil { 15 | didSet { 16 | recorder?.delegate = self 17 | logText.string = recorder?.lines.joined(separator: "\n") ?? "" 18 | } 19 | } 20 | @IBOutlet var logText:NSTextView! = nil 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | // Do view setup here. 25 | } 26 | 27 | func appendLogText(_ message:String) { 28 | logText.string.append(message + "\n") 29 | logText.scrollToEndOfDocument(self) 30 | } 31 | 32 | @IBAction func clearLogPressed(_ sender:NSButton) { 33 | recorder?.reset() 34 | } 35 | 36 | @IBAction func saveCSVPressed(_ sender:NSButton) { 37 | let savePanel = NSSavePanel() 38 | savePanel.allowedFileTypes = ["txt"] 39 | savePanel.beginSheetModal(for: self.view.window!) { (result) -> Void in 40 | if result == NSApplication.ModalResponse.OK { 41 | savePanel.orderOut(self) 42 | if let selectedUrl = savePanel.url { 43 | let contents:String = self.logText.string 44 | do { 45 | try contents.write(to: selectedUrl, atomically: true, encoding: String.Encoding.utf8) 46 | } catch { 47 | //TODO: Display error to user 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | 55 | func recorder(_ recorder: LogRecorder, appendedLine: String) { 56 | appendLogText(appendedLine) 57 | } 58 | 59 | func recorderDidReset(_ recorder: LogRecorder) { 60 | logText.string = "" 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Bluetility/BluetilityWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BluetilityWindowController.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 11/9/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Logging 11 | 12 | class BluetilityWindowController: NSWindowController { 13 | 14 | let recorder: LogRecorder = LogRecorder() 15 | var viewController:ViewController? 16 | @IBOutlet var refreshItem: NSToolbarItem! 17 | @IBOutlet var sortItem: NSToolbarItem! 18 | @IBOutlet var logItem: NSToolbarItem! 19 | @IBOutlet var searchItemField: NSSearchField! 20 | 21 | required init?(coder: NSCoder) { 22 | super.init(coder: coder) 23 | LoggingSystem.bootstrap { label in 24 | return BluetilityLogHandler(label: label, recorder: self.recorder) 25 | } 26 | } 27 | 28 | override func windowDidLoad() { 29 | super.windowDidLoad() 30 | self.shouldCascadeWindows = false 31 | window?.setFrameAutosaveName("bluetility") 32 | 33 | // Implement this method to handle any initialization after your window controller's window has been loaded from its nib file. 34 | viewController = self.contentViewController as? ViewController 35 | viewController?.recorder = recorder 36 | logItem.target = viewController 37 | sortItem.target = viewController 38 | refreshItem.target = viewController 39 | logItem.action = #selector(ViewController.logPressed(_:)) 40 | sortItem.action = #selector(ViewController.sortPressed(_:)) 41 | refreshItem.action = #selector(ViewController.refreshPressed(_:)) 42 | searchItemField.delegate = self 43 | } 44 | } 45 | 46 | extension BluetilityWindowController: NSSearchFieldDelegate { 47 | func controlTextDidChange(_ obj: Notification) { 48 | guard let searchField = obj.object as? NSSearchField, searchField == searchItemField else { 49 | return 50 | } 51 | 52 | viewController?.searchTextDidChange(value: searchField.stringValue) 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /Bluetility/PasteboardBrowser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasteboardBrowser.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 12/16/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol PasteboardBrowserDelegate { 12 | func browser(_ browser: PasteboardBrowser, pasteboardStringFor indexPath: IndexPath) -> String? 13 | func browser(_ browser: PasteboardBrowser, menuFor cell: NSBrowserCell, atRow row: Int, column: Int) -> NSMenu? 14 | } 15 | 16 | class PasteboardBrowser: NSBrowser { 17 | 18 | @IBAction 19 | func copy(_ sender:AnyObject) { 20 | var string:String? = nil 21 | if let pasteboardDelegate = self.delegate as? PasteboardBrowserDelegate, 22 | let indexPath = self.selectionIndexPath 23 | { 24 | string = pasteboardDelegate.browser(self, pasteboardStringFor: indexPath) 25 | } 26 | if string == nil { 27 | string = (self.selectedCell() as? NSBrowserCell)?.title 28 | } 29 | guard let pasteString = string else {return} 30 | let pb = NSPasteboard.general 31 | pb.declareTypes([.string], owner: self) 32 | pb.setString(pasteString, forType: .string) 33 | } 34 | 35 | override func reloadData(forRowIndexes rowIndexes: IndexSet, inColumn column: Int) { 36 | for rowIndex in rowIndexes { 37 | guard let cell = loadedCell(atRow: rowIndex, column: column) else { continue } 38 | delegate?.browser?(self, willDisplayCell: cell, atRow: rowIndex, column: column) 39 | } 40 | } 41 | 42 | override func menu(for event: NSEvent) -> NSMenu? { 43 | let windowPoint = event.locationInWindow 44 | let viewPoint = self.convert(windowPoint, from: window?.contentView) 45 | var row: Int = 0 46 | var column: Int = 0 47 | guard getRow(&row, column: &column, for: viewPoint) else { return nil } 48 | selectRow(row, inColumn: column) 49 | self.sendAction() 50 | 51 | guard 52 | let cell = selectedCell() as? NSBrowserCell, 53 | let delegate = delegate as? PasteboardBrowserDelegate 54 | else { 55 | return nil 56 | } 57 | return delegate.browser(self, menuFor: cell, atRow: row, column: column) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Bluetility/Scanner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scanner.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 11/9/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import CoreBluetooth 10 | 11 | protocol ScannerDelegate: AnyObject { 12 | func scanner(_ scanner: Scanner, didUpdateDevices: [Device]) 13 | } 14 | 15 | class Scanner: NSObject { 16 | 17 | var central:CBCentralManager 18 | weak var delegate:ScannerDelegate? = nil 19 | var devices:[Device] = [] 20 | var started:Bool = false 21 | 22 | override init() { 23 | central = CBCentralManager(delegate: nil, queue: nil) 24 | super.init() 25 | central.delegate = self 26 | } 27 | 28 | func start() { 29 | started = true 30 | devices = [] 31 | startIfReady() 32 | } 33 | 34 | private func startIfReady() { 35 | if central.state == .poweredOn && started { 36 | central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey:false]) 37 | } 38 | } 39 | 40 | func stop() { 41 | started = false 42 | central.stopScan() 43 | } 44 | 45 | func restart() { 46 | stop() 47 | start() 48 | } 49 | 50 | } 51 | 52 | extension Scanner : CBCentralManagerDelegate { 53 | 54 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 55 | startIfReady() 56 | } 57 | 58 | func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 59 | guard let existingDevice = devices.first(where: { $0.peripheral == peripheral } ) else { 60 | let newDevice = Device(scanner: self, peripheral: peripheral, advertisingData: advertisementData, rssi: RSSI.intValue) 61 | devices.append(newDevice) 62 | return 63 | } 64 | 65 | existingDevice.rssi = RSSI.intValue 66 | existingDevice.updateAdvertisingData(advertisementData) 67 | 68 | delegate?.scanner(self, didUpdateDevices: devices) 69 | } 70 | 71 | func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 72 | guard let device = devices.first(where:{ $0.peripheral == peripheral }) else { return } 73 | device.peripheralDidConnect() 74 | } 75 | 76 | func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 77 | guard let device = devices.first(where:{ $0.peripheral == peripheral }) else { return } 78 | device.peripheralDidDisconnect(error: error) 79 | } 80 | } 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Bluetility.xcodeproj/xcshareddata/xcschemes/Bluetility Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /Bluetility.xcodeproj/xcshareddata/xcschemes/Bluetility Debug.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Bluetility/BluetilityLogHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | import struct Logging.Logger 4 | import os 5 | 6 | public struct BluetilityLogHandler: LogHandler { 7 | public var logLevel: Logger.Level = .debug 8 | public var recordedLogLevel: Logger.Level = .info 9 | public let label: String 10 | private let oslogger: OSLog 11 | private let recorder: LogRecorder? 12 | 13 | public init(label: String) { 14 | self.label = label 15 | self.oslogger = OSLog(subsystem: label, category: "") 16 | self.recorder = nil 17 | } 18 | 19 | public init(label: String, recorder: LogRecorder) { 20 | self.label = label 21 | self.oslogger = OSLog(subsystem: label, category: "") 22 | self.recorder = recorder 23 | } 24 | 25 | public init(label: String, log: OSLog, recorder: LogRecorder) { 26 | self.label = label 27 | self.oslogger = log 28 | self.recorder = recorder 29 | } 30 | 31 | public func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) { 32 | var combinedPrettyMetadata = self.prettyMetadata 33 | if let metadataOverride = metadata, !metadataOverride.isEmpty { 34 | combinedPrettyMetadata = self.prettify( 35 | self.metadata.merging(metadataOverride) { 36 | return $1 37 | } 38 | ) 39 | } 40 | 41 | var formedMessage = message.description 42 | if combinedPrettyMetadata != nil { 43 | formedMessage += " -- " + combinedPrettyMetadata! 44 | } 45 | os_log("%{public}@", log: self.oslogger, type: OSLogType.from(loggerLevel: level), formedMessage as NSString) 46 | 47 | if level >= recordedLogLevel { 48 | recorder?.append(message.description) 49 | } 50 | } 51 | 52 | private var prettyMetadata: String? 53 | public var metadata = Logger.Metadata() { 54 | didSet { 55 | self.prettyMetadata = self.prettify(self.metadata) 56 | } 57 | } 58 | 59 | /// Add, remove, or change the logging metadata. 60 | /// - parameters: 61 | /// - metadataKey: the key for the metadata item. 62 | public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { 63 | get { 64 | return self.metadata[metadataKey] 65 | } 66 | set { 67 | self.metadata[metadataKey] = newValue 68 | } 69 | } 70 | 71 | private func prettify(_ metadata: Logger.Metadata) -> String? { 72 | if metadata.isEmpty { 73 | return nil 74 | } 75 | return metadata.map { 76 | "\($0)=\($1)" 77 | }.joined(separator: " ") 78 | } 79 | } 80 | 81 | extension OSLogType { 82 | static func from(loggerLevel: Logger.Level) -> Self { 83 | switch loggerLevel { 84 | case .trace: 85 | /// `OSLog` doesn't have `trace`, so use `debug` 86 | return .debug 87 | case .debug: 88 | return .debug 89 | case .info: 90 | return .info 91 | case .notice: 92 | // https://developer.apple.com/documentation/os/logging/generating_log_messages_from_your_code 93 | // According to the documentation, `default` is `notice`. 94 | return .default 95 | case .warning: 96 | /// `OSLog` doesn't have `warning`, so use `info` 97 | return .info 98 | case .error: 99 | return .error 100 | case .critical: 101 | return .fault 102 | } 103 | } 104 | } 105 | 106 | public class LogRecorder { 107 | var lines: [String] = [] 108 | 109 | var delegate: LogRecorderDelegate? = nil 110 | 111 | func append(_ line: String) { 112 | lines.append(line) 113 | delegate?.recorder(self, appendedLine: line) 114 | } 115 | 116 | func reset() { 117 | lines = [] 118 | delegate?.recorderDidReset(self) 119 | } 120 | } 121 | 122 | public protocol LogRecorderDelegate: AnyObject { 123 | func recorder(_ recorder: LogRecorder, appendedLine: String) 124 | func recorderDidReset(_ recorder: LogRecorder) 125 | } 126 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | addressable (2.8.7) 9 | public_suffix (>= 2.0.2, < 7.0) 10 | artifactory (3.0.17) 11 | atomos (0.1.3) 12 | aws-eventstream (1.3.0) 13 | aws-partitions (1.1044.0) 14 | aws-sdk-core (3.217.1) 15 | aws-eventstream (~> 1, >= 1.3.0) 16 | aws-partitions (~> 1, >= 1.992.0) 17 | aws-sigv4 (~> 1.9) 18 | jmespath (~> 1, >= 1.6.1) 19 | aws-sdk-kms (1.97.0) 20 | aws-sdk-core (~> 3, >= 3.216.0) 21 | aws-sigv4 (~> 1.5) 22 | aws-sdk-s3 (1.179.0) 23 | aws-sdk-core (~> 3, >= 3.216.0) 24 | aws-sdk-kms (~> 1) 25 | aws-sigv4 (~> 1.5) 26 | aws-sigv4 (1.11.0) 27 | aws-eventstream (~> 1, >= 1.0.2) 28 | babosa (1.0.4) 29 | base64 (0.2.0) 30 | claide (1.1.0) 31 | colored (1.2) 32 | colored2 (3.1.2) 33 | commander (4.6.0) 34 | highline (~> 2.0.0) 35 | declarative (0.0.20) 36 | digest-crc (0.7.0) 37 | rake (>= 12.0.0, < 14.0.0) 38 | domain_name (0.6.20240107) 39 | dotenv (2.8.1) 40 | emoji_regex (3.2.3) 41 | excon (0.112.0) 42 | faraday (1.10.4) 43 | faraday-em_http (~> 1.0) 44 | faraday-em_synchrony (~> 1.0) 45 | faraday-excon (~> 1.1) 46 | faraday-httpclient (~> 1.0) 47 | faraday-multipart (~> 1.0) 48 | faraday-net_http (~> 1.0) 49 | faraday-net_http_persistent (~> 1.0) 50 | faraday-patron (~> 1.0) 51 | faraday-rack (~> 1.0) 52 | faraday-retry (~> 1.0) 53 | ruby2_keywords (>= 0.0.4) 54 | faraday-cookie_jar (0.0.7) 55 | faraday (>= 0.8.0) 56 | http-cookie (~> 1.0.0) 57 | faraday-em_http (1.0.0) 58 | faraday-em_synchrony (1.0.0) 59 | faraday-excon (1.1.0) 60 | faraday-httpclient (1.0.1) 61 | faraday-multipart (1.1.0) 62 | multipart-post (~> 2.0) 63 | faraday-net_http (1.0.2) 64 | faraday-net_http_persistent (1.2.0) 65 | faraday-patron (1.0.0) 66 | faraday-rack (1.0.0) 67 | faraday-retry (1.0.3) 68 | faraday_middleware (1.2.1) 69 | faraday (~> 1.0) 70 | fastimage (2.4.0) 71 | fastlane (2.226.0) 72 | CFPropertyList (>= 2.3, < 4.0.0) 73 | addressable (>= 2.8, < 3.0.0) 74 | artifactory (~> 3.0) 75 | aws-sdk-s3 (~> 1.0) 76 | babosa (>= 1.0.3, < 2.0.0) 77 | bundler (>= 1.12.0, < 3.0.0) 78 | colored (~> 1.2) 79 | commander (~> 4.6) 80 | dotenv (>= 2.1.1, < 3.0.0) 81 | emoji_regex (>= 0.1, < 4.0) 82 | excon (>= 0.71.0, < 1.0.0) 83 | faraday (~> 1.0) 84 | faraday-cookie_jar (~> 0.0.6) 85 | faraday_middleware (~> 1.0) 86 | fastimage (>= 2.1.0, < 3.0.0) 87 | fastlane-sirp (>= 1.0.0) 88 | gh_inspector (>= 1.1.2, < 2.0.0) 89 | google-apis-androidpublisher_v3 (~> 0.3) 90 | google-apis-playcustomapp_v1 (~> 0.1) 91 | google-cloud-env (>= 1.6.0, < 2.0.0) 92 | google-cloud-storage (~> 1.31) 93 | highline (~> 2.0) 94 | http-cookie (~> 1.0.5) 95 | json (< 3.0.0) 96 | jwt (>= 2.1.0, < 3) 97 | mini_magick (>= 4.9.4, < 5.0.0) 98 | multipart-post (>= 2.0.0, < 3.0.0) 99 | naturally (~> 2.2) 100 | optparse (>= 0.1.1, < 1.0.0) 101 | plist (>= 3.1.0, < 4.0.0) 102 | rubyzip (>= 2.0.0, < 3.0.0) 103 | security (= 0.1.5) 104 | simctl (~> 1.6.3) 105 | terminal-notifier (>= 2.0.0, < 3.0.0) 106 | terminal-table (~> 3) 107 | tty-screen (>= 0.6.3, < 1.0.0) 108 | tty-spinner (>= 0.8.0, < 1.0.0) 109 | word_wrap (~> 1.0.0) 110 | xcodeproj (>= 1.13.0, < 2.0.0) 111 | xcpretty (~> 0.4.0) 112 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 113 | fastlane-sirp (1.0.0) 114 | sysrandom (~> 1.0) 115 | gh_inspector (1.1.3) 116 | google-apis-androidpublisher_v3 (0.54.0) 117 | google-apis-core (>= 0.11.0, < 2.a) 118 | google-apis-core (0.11.3) 119 | addressable (~> 2.5, >= 2.5.1) 120 | googleauth (>= 0.16.2, < 2.a) 121 | httpclient (>= 2.8.1, < 3.a) 122 | mini_mime (~> 1.0) 123 | representable (~> 3.0) 124 | retriable (>= 2.0, < 4.a) 125 | rexml 126 | google-apis-iamcredentials_v1 (0.17.0) 127 | google-apis-core (>= 0.11.0, < 2.a) 128 | google-apis-playcustomapp_v1 (0.13.0) 129 | google-apis-core (>= 0.11.0, < 2.a) 130 | google-apis-storage_v1 (0.31.0) 131 | google-apis-core (>= 0.11.0, < 2.a) 132 | google-cloud-core (1.7.1) 133 | google-cloud-env (>= 1.0, < 3.a) 134 | google-cloud-errors (~> 1.0) 135 | google-cloud-env (1.6.0) 136 | faraday (>= 0.17.3, < 3.0) 137 | google-cloud-errors (1.4.0) 138 | google-cloud-storage (1.47.0) 139 | addressable (~> 2.8) 140 | digest-crc (~> 0.4) 141 | google-apis-iamcredentials_v1 (~> 0.1) 142 | google-apis-storage_v1 (~> 0.31.0) 143 | google-cloud-core (~> 1.6) 144 | googleauth (>= 0.16.2, < 2.a) 145 | mini_mime (~> 1.0) 146 | googleauth (1.8.1) 147 | faraday (>= 0.17.3, < 3.a) 148 | jwt (>= 1.4, < 3.0) 149 | multi_json (~> 1.11) 150 | os (>= 0.9, < 2.0) 151 | signet (>= 0.16, < 2.a) 152 | highline (2.0.3) 153 | http-cookie (1.0.8) 154 | domain_name (~> 0.5) 155 | httpclient (2.8.3) 156 | jmespath (1.6.2) 157 | json (2.9.1) 158 | jwt (2.10.1) 159 | base64 160 | mini_magick (4.13.2) 161 | mini_mime (1.1.5) 162 | multi_json (1.15.0) 163 | multipart-post (2.4.1) 164 | nanaimo (0.4.0) 165 | naturally (2.2.1) 166 | nkf (0.2.0) 167 | optparse (0.6.0) 168 | os (1.1.4) 169 | plist (3.7.2) 170 | public_suffix (6.0.1) 171 | rake (13.2.1) 172 | representable (3.2.0) 173 | declarative (< 0.1.0) 174 | trailblazer-option (>= 0.1.1, < 0.2.0) 175 | uber (< 0.2.0) 176 | retriable (3.1.2) 177 | rexml (3.4.0) 178 | rouge (3.28.0) 179 | ruby2_keywords (0.0.5) 180 | rubyzip (2.4.1) 181 | security (0.1.5) 182 | signet (0.19.0) 183 | addressable (~> 2.8) 184 | faraday (>= 0.17.5, < 3.a) 185 | jwt (>= 1.5, < 3.0) 186 | multi_json (~> 1.10) 187 | simctl (1.6.10) 188 | CFPropertyList 189 | naturally 190 | sysrandom (1.0.5) 191 | terminal-notifier (2.0.0) 192 | terminal-table (3.0.2) 193 | unicode-display_width (>= 1.1.1, < 3) 194 | trailblazer-option (0.1.2) 195 | tty-cursor (0.7.1) 196 | tty-screen (0.8.2) 197 | tty-spinner (0.9.3) 198 | tty-cursor (~> 0.7) 199 | uber (0.1.0) 200 | unicode-display_width (2.6.0) 201 | word_wrap (1.0.0) 202 | xcodeproj (1.27.0) 203 | CFPropertyList (>= 2.3.3, < 4.0) 204 | atomos (~> 0.1.3) 205 | claide (>= 1.0.2, < 2.0) 206 | colored2 (~> 3.1) 207 | nanaimo (~> 0.4.0) 208 | rexml (>= 3.3.6, < 4.0) 209 | xcpretty (0.4.0) 210 | rouge (~> 3.28.0) 211 | xcpretty-travis-formatter (1.0.1) 212 | xcpretty (~> 0.2, >= 0.0.7) 213 | 214 | PLATFORMS 215 | ruby 216 | 217 | DEPENDENCIES 218 | fastlane 219 | 220 | BUNDLED WITH 221 | 2.5.23 222 | -------------------------------------------------------------------------------- /Bluetility/Device.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Device.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 7/1/20. 6 | // Copyright © 2020 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import CoreBluetooth 10 | import Logging 11 | 12 | protocol DeviceDelegate: AnyObject { 13 | func deviceDidConnect(_ device: Device) 14 | func deviceDidDisconnect(_ device: Device) 15 | func deviceDidUpdateName(_ device: Device) 16 | func device(_ device: Device, updated services: [CBService]) 17 | func device(_ device: Device, updated characteristics: [CBCharacteristic], for service: CBService) 18 | func device(_ device: Device, updatedValueFor characteristic: CBCharacteristic) 19 | } 20 | 21 | class Device : NSObject { 22 | let peripheral: CBPeripheral 23 | unowned var scanner: Scanner 24 | var advertisingData: [String:Any] 25 | var rssi: Int 26 | let logger = Logger(label: "com.rossible.Bluetility.Device") 27 | 28 | weak var delegate: DeviceDelegate? = nil 29 | 30 | // Transient data 31 | var manufacturerName: String? = nil 32 | var modelName: String? = nil 33 | var companyIdentifier: String? = nil 34 | var company: String? = nil 35 | 36 | init(scanner: Scanner, peripheral: CBPeripheral, advertisingData: [String: Any], rssi: Int) { 37 | self.scanner = scanner 38 | self.peripheral = peripheral 39 | self.advertisingData = advertisingData 40 | self.rssi = rssi 41 | 42 | super.init() 43 | 44 | extractCompanyIdentifier() 45 | peripheral.delegate = self 46 | } 47 | 48 | deinit { 49 | peripheral.delegate = nil 50 | } 51 | 52 | func extractCompanyIdentifier() { 53 | if let manufacturerData = self.advertisingData[CBAdvertisementDataManufacturerDataKey] as? Data { 54 | if manufacturerData.count >= 2 { 55 | let companyIdentifier = manufacturerData[0..<2].reversed().hexString 56 | self.companyIdentifier = companyIdentifier 57 | self.company = COMPANY_IDENTIFIERS[companyIdentifier] 58 | } 59 | } 60 | } 61 | 62 | func updateAdvertisingData(_ newData:[String: Any]) { 63 | self.advertisingData += newData 64 | extractCompanyIdentifier() 65 | } 66 | 67 | var friendlyName : String { 68 | if let advertisedName = advertisingData[CBAdvertisementDataLocalNameKey] as? String { 69 | return advertisedName 70 | } 71 | if let peripheralName = peripheral.name { 72 | return peripheralName 73 | } 74 | let infoFields = [manufacturerName, modelName].compactMap({$0}) 75 | if infoFields.count > 0 { 76 | return infoFields.joined(separator: " ") 77 | } 78 | 79 | return "Untitled" 80 | } 81 | 82 | var services: [CBService] { 83 | return peripheral.services ?? [] 84 | } 85 | 86 | func connect() { 87 | scanner.central.connect(self.peripheral, options: [:]) 88 | } 89 | 90 | func disconnect() { 91 | scanner.central.cancelPeripheralConnection(self.peripheral) 92 | } 93 | 94 | func discoverCharacteristics(for service: CBService) { 95 | peripheral.discoverCharacteristics(nil, for: service) 96 | } 97 | 98 | func read(characteristic: CBCharacteristic) { 99 | peripheral.readValue(for: characteristic) 100 | } 101 | 102 | func write(data: Data, for characteristic: CBCharacteristic, type:CBCharacteristicWriteType) { 103 | peripheral.writeValue(data, for: characteristic, type: type) 104 | } 105 | 106 | func setNotify(_ enabled: Bool, for characteristic: CBCharacteristic) { 107 | peripheral.setNotifyValue(enabled, for: characteristic) 108 | } 109 | } 110 | 111 | extension Device : CBPeripheralDelegate { 112 | func peripheralDidConnect() { 113 | peripheral.discoverServices(nil) 114 | delegate?.deviceDidConnect(self) 115 | } 116 | 117 | func peripheralDidDisconnect(error: Error?) { 118 | delegate?.deviceDidDisconnect(self) 119 | } 120 | 121 | func peripheralDidUpdateName(_ peripheral: CBPeripheral) { 122 | delegate?.deviceDidUpdateName(self) 123 | } 124 | 125 | func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 126 | if let error = error { 127 | // TODO: report an error? 128 | logger.error("Peripheral: \(peripheral.name ?? "-") didDiscoverServices encountered error: \(error)") 129 | return 130 | } 131 | let services = peripheral.services ?? [] 132 | 133 | logger.info("Peripheral: \(peripheral.name ?? "-") didDiscoverServices: \(services.map({ $0.uuid }))") 134 | 135 | handleSpecialServices(services) 136 | 137 | delegate?.device(self, updated: services) 138 | } 139 | 140 | func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 141 | if let error = error { 142 | // TODO: report an error? 143 | logger.error("Peripheral: \(peripheral.name ?? "-") didDiscoverCharacteristicsFor: \(service) encountered error: \(error)") 144 | return 145 | } 146 | let characteristics = service.characteristics ?? [] 147 | 148 | logger.info("Peripheral: \(peripheral.name ?? "-") didDiscoverCharacteristics: \(characteristics.map({ $0.uuid })) for: \(service.uuid) ") 149 | handleSpecialCharacteristics(characteristics) 150 | 151 | delegate?.device(self, updated: characteristics, for: service) 152 | } 153 | 154 | func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { 155 | if let error = error { 156 | // TODO: report an error? 157 | logger.error("Peripheral: \(peripheral.name ?? "-") didUpdateValueFor: \(characteristic) encountered error: \(error)") 158 | return 159 | } 160 | logger.info("Peripheral: \(peripheral.name ?? "-") didUpdateValueFor: \(characteristic.uuid) to: \(characteristic.value?.hexString ?? "nil")") 161 | 162 | handleSpecialCharacteristic(characteristic) 163 | delegate?.device(self, updatedValueFor: characteristic) 164 | } 165 | 166 | func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { 167 | if let error = error { 168 | // TODO: report an error? 169 | logger.error("Peripheral: \(peripheral.name ?? "-") didWriteValueFor: \(characteristic) encountered error: \(error)") 170 | } 171 | // TODO: report successful write? 172 | logger.info("Peripheral: \(peripheral.name ?? "-") didWriteValueFor: \(characteristic) successfully") 173 | } 174 | 175 | } 176 | 177 | // MARK: Handle Special Characteristics 178 | 179 | fileprivate let specialServiceUUIDs = [ 180 | CBUUID(string: "180A"), // Device Information Service 181 | ] 182 | 183 | fileprivate let manufacturerNameUUID = CBUUID(string: "2A29") 184 | fileprivate let modelNumberUUID = CBUUID(string: "2A24") 185 | 186 | fileprivate let specialCharacteristicUUIDs = [ 187 | manufacturerNameUUID, // Manufacturer Name 188 | modelNumberUUID, // Model Number 189 | ] 190 | 191 | extension Device { 192 | func handleSpecialServices(_ services: [CBService]) { 193 | for service in services { 194 | if specialServiceUUIDs.contains(service.uuid) { 195 | peripheral.discoverCharacteristics(specialCharacteristicUUIDs, for: service) 196 | } 197 | } 198 | } 199 | 200 | func handleSpecialCharacteristics(_ characteristics: [CBCharacteristic]) { 201 | for characteristic in characteristics { 202 | if specialCharacteristicUUIDs.contains(characteristic.uuid) { 203 | peripheral.readValue(for: characteristic) 204 | handleSpecialCharacteristic(characteristic) 205 | } 206 | } 207 | } 208 | 209 | func handleSpecialCharacteristic(_ characteristic: CBCharacteristic) { 210 | guard let value = characteristic.value else { return } 211 | 212 | if specialCharacteristicUUIDs.contains(characteristic.uuid) { 213 | switch characteristic.uuid { 214 | case manufacturerNameUUID: 215 | manufacturerName = String(bytes: value, encoding: .utf8) 216 | case modelNumberUUID: 217 | modelName = String(bytes: value, encoding: .utf8) 218 | default: 219 | assertionFailure("Forgot to handle one of the UUIDs in specialCharacteristicUUIDs: \(characteristic.uuid)") 220 | logger.critical("Forgot to handle one of the UUIDs in specialCharacteristicUUIDs: \(characteristic.uuid)") 221 | } 222 | delegate?.deviceDidUpdateName(self) 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Bluetility.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3749318B1BF169C90087E39F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3749318A1BF169C90087E39F /* AppDelegate.swift */; }; 11 | 3749318D1BF169C90087E39F /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3749318C1BF169C90087E39F /* ViewController.swift */; }; 12 | 3749318F1BF169C90087E39F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3749318E1BF169C90087E39F /* Assets.xcassets */; }; 13 | 374931921BF169C90087E39F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 374931901BF169C90087E39F /* Main.storyboard */; }; 14 | 3749319A1BF16D980087E39F /* BluetilityWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374931991BF16D980087E39F /* BluetilityWindowController.swift */; }; 15 | 3749319C1BF173470087E39F /* Scanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3749319B1BF173470087E39F /* Scanner.swift */; }; 16 | 3769BDE01C308814004C089D /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = 3769BDDF1C308814004C089D /* Credits.html */; }; 17 | 3769BDE41C32FB0D004C089D /* LogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769BDE31C32FB0D004C089D /* LogViewController.swift */; }; 18 | 37F9CCD51C21D856003580CB /* PasteboardBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F9CCD41C21D856003580CB /* PasteboardBrowser.swift */; }; 19 | 505C6E80233FC8C400ABC0A3 /* NSTextField+Shake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505C6E7F233FC8C400ABC0A3 /* NSTextField+Shake.swift */; }; 20 | D89F265029A1B5DF00061958 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = D89F264F29A1B5DF00061958 /* Logging */; }; 21 | D89F265529A1C56300061958 /* BluetilityLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89F265429A1C56300061958 /* BluetilityLogHandler.swift */; }; 22 | D89F71D324ACF02800861217 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89F71D224ACF02700861217 /* Device.swift */; }; 23 | D89F71D724ACF67500861217 /* Swift+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89F71D624ACF67500861217 /* Swift+Extensions.swift */; }; 24 | D8F204122D5088D900AAB8F8 /* CompanyIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F204112D5088D200AAB8F8 /* CompanyIdentifiers.swift */; }; 25 | /* End PBXBuildFile section */ 26 | 27 | /* Begin PBXFileReference section */ 28 | 374931871BF169C90087E39F /* Bluetility.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bluetility.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 3749318A1BF169C90087E39F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 30 | 3749318C1BF169C90087E39F /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 31 | 3749318E1BF169C90087E39F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 32 | 374931911BF169C90087E39F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 33 | 374931931BF169C90087E39F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 34 | 374931991BF16D980087E39F /* BluetilityWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetilityWindowController.swift; sourceTree = ""; }; 35 | 3749319B1BF173470087E39F /* Scanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Scanner.swift; sourceTree = ""; }; 36 | 3769BDDF1C308814004C089D /* Credits.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Credits.html; sourceTree = ""; }; 37 | 3769BDE31C32FB0D004C089D /* LogViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogViewController.swift; sourceTree = ""; }; 38 | 37F9CCD41C21D856003580CB /* PasteboardBrowser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasteboardBrowser.swift; sourceTree = ""; }; 39 | 505C6E7F233FC8C400ABC0A3 /* NSTextField+Shake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextField+Shake.swift"; sourceTree = ""; }; 40 | D82AF517222AF834005D80D1 /* fastlane */ = {isa = PBXFileReference; lastKnownFileType = folder; path = fastlane; sourceTree = ""; }; 41 | D85908F12427E34700CBECC9 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; 42 | D85908F22427E34700CBECC9 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 43 | D85908F32427E34700CBECC9 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 44 | D85908F42427E34700CBECC9 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 45 | D8789F0E24390B7900B83AA4 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 46 | D89F265429A1C56300061958 /* BluetilityLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetilityLogHandler.swift; sourceTree = ""; }; 47 | D89F71D024AC084B00861217 /* Gemfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile; sourceTree = ""; }; 48 | D89F71D124AC084B00861217 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; 49 | D89F71D224ACF02700861217 /* Device.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = ""; }; 50 | D89F71D624ACF67500861217 /* Swift+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Swift+Extensions.swift"; sourceTree = ""; }; 51 | D8F204112D5088D200AAB8F8 /* CompanyIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyIdentifiers.swift; sourceTree = ""; }; 52 | /* End PBXFileReference section */ 53 | 54 | /* Begin PBXFrameworksBuildPhase section */ 55 | 374931841BF169C90087E39F /* Frameworks */ = { 56 | isa = PBXFrameworksBuildPhase; 57 | buildActionMask = 2147483647; 58 | files = ( 59 | D89F265029A1B5DF00061958 /* Logging in Frameworks */, 60 | ); 61 | runOnlyForDeploymentPostprocessing = 0; 62 | }; 63 | /* End PBXFrameworksBuildPhase section */ 64 | 65 | /* Begin PBXGroup section */ 66 | 3749317E1BF169C90087E39F = { 67 | isa = PBXGroup; 68 | children = ( 69 | D8789F0E24390B7900B83AA4 /* README.md */, 70 | 374931891BF169C90087E39F /* Bluetility */, 71 | D85908F02427E34700CBECC9 /* Config */, 72 | 374931881BF169C90087E39F /* Products */, 73 | D82AF517222AF834005D80D1 /* fastlane */, 74 | D89F71D024AC084B00861217 /* Gemfile */, 75 | D89F71D124AC084B00861217 /* LICENSE.md */, 76 | ); 77 | sourceTree = ""; 78 | }; 79 | 374931881BF169C90087E39F /* Products */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | 374931871BF169C90087E39F /* Bluetility.app */, 83 | ); 84 | name = Products; 85 | sourceTree = ""; 86 | }; 87 | 374931891BF169C90087E39F /* Bluetility */ = { 88 | isa = PBXGroup; 89 | children = ( 90 | D8F204112D5088D200AAB8F8 /* CompanyIdentifiers.swift */, 91 | 3749318A1BF169C90087E39F /* AppDelegate.swift */, 92 | 374931991BF16D980087E39F /* BluetilityWindowController.swift */, 93 | D89F71D224ACF02700861217 /* Device.swift */, 94 | 3749319B1BF173470087E39F /* Scanner.swift */, 95 | 3749318C1BF169C90087E39F /* ViewController.swift */, 96 | 3749318E1BF169C90087E39F /* Assets.xcassets */, 97 | 374931901BF169C90087E39F /* Main.storyboard */, 98 | 374931931BF169C90087E39F /* Info.plist */, 99 | 37F9CCD41C21D856003580CB /* PasteboardBrowser.swift */, 100 | 3769BDDF1C308814004C089D /* Credits.html */, 101 | 3769BDE31C32FB0D004C089D /* LogViewController.swift */, 102 | D89F71D624ACF67500861217 /* Swift+Extensions.swift */, 103 | 505C6E7F233FC8C400ABC0A3 /* NSTextField+Shake.swift */, 104 | D89F265429A1C56300061958 /* BluetilityLogHandler.swift */, 105 | ); 106 | path = Bluetility; 107 | sourceTree = ""; 108 | }; 109 | D85908F02427E34700CBECC9 /* Config */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | D85908F12427E34700CBECC9 /* Shared.xcconfig */, 113 | D85908F22427E34700CBECC9 /* Debug.xcconfig */, 114 | D85908F32427E34700CBECC9 /* Release.xcconfig */, 115 | D85908F42427E34700CBECC9 /* Warnings.xcconfig */, 116 | ); 117 | path = Config; 118 | sourceTree = ""; 119 | }; 120 | /* End PBXGroup section */ 121 | 122 | /* Begin PBXNativeTarget section */ 123 | 374931861BF169C90087E39F /* Bluetility */ = { 124 | isa = PBXNativeTarget; 125 | buildConfigurationList = 374931961BF169C90087E39F /* Build configuration list for PBXNativeTarget "Bluetility" */; 126 | buildPhases = ( 127 | 374931831BF169C90087E39F /* Sources */, 128 | 374931841BF169C90087E39F /* Frameworks */, 129 | 374931851BF169C90087E39F /* Resources */, 130 | ); 131 | buildRules = ( 132 | ); 133 | dependencies = ( 134 | ); 135 | name = Bluetility; 136 | packageProductDependencies = ( 137 | D89F264F29A1B5DF00061958 /* Logging */, 138 | ); 139 | productName = Bluetility; 140 | productReference = 374931871BF169C90087E39F /* Bluetility.app */; 141 | productType = "com.apple.product-type.application"; 142 | }; 143 | /* End PBXNativeTarget section */ 144 | 145 | /* Begin PBXProject section */ 146 | 3749317F1BF169C90087E39F /* Project object */ = { 147 | isa = PBXProject; 148 | attributes = { 149 | LastSwiftUpdateCheck = 0710; 150 | LastUpgradeCheck = 1320; 151 | ORGANIZATIONNAME = "Joseph Ross"; 152 | TargetAttributes = { 153 | 374931861BF169C90087E39F = { 154 | CreatedOnToolsVersion = 7.1; 155 | LastSwiftMigration = 1130; 156 | ProvisioningStyle = Manual; 157 | SystemCapabilities = { 158 | com.apple.HardenedRuntime = { 159 | enabled = 1; 160 | }; 161 | }; 162 | }; 163 | }; 164 | }; 165 | buildConfigurationList = 374931821BF169C90087E39F /* Build configuration list for PBXProject "Bluetility" */; 166 | compatibilityVersion = "Xcode 3.2"; 167 | developmentRegion = en; 168 | hasScannedForEncodings = 0; 169 | knownRegions = ( 170 | en, 171 | Base, 172 | ); 173 | mainGroup = 3749317E1BF169C90087E39F; 174 | packageReferences = ( 175 | D89F264E29A1B5DF00061958 /* XCRemoteSwiftPackageReference "swift-log" */, 176 | ); 177 | productRefGroup = 374931881BF169C90087E39F /* Products */; 178 | projectDirPath = ""; 179 | projectRoot = ""; 180 | targets = ( 181 | 374931861BF169C90087E39F /* Bluetility */, 182 | ); 183 | }; 184 | /* End PBXProject section */ 185 | 186 | /* Begin PBXResourcesBuildPhase section */ 187 | 374931851BF169C90087E39F /* Resources */ = { 188 | isa = PBXResourcesBuildPhase; 189 | buildActionMask = 2147483647; 190 | files = ( 191 | 3749318F1BF169C90087E39F /* Assets.xcassets in Resources */, 192 | 3769BDE01C308814004C089D /* Credits.html in Resources */, 193 | 374931921BF169C90087E39F /* Main.storyboard in Resources */, 194 | ); 195 | runOnlyForDeploymentPostprocessing = 0; 196 | }; 197 | /* End PBXResourcesBuildPhase section */ 198 | 199 | /* Begin PBXSourcesBuildPhase section */ 200 | 374931831BF169C90087E39F /* Sources */ = { 201 | isa = PBXSourcesBuildPhase; 202 | buildActionMask = 2147483647; 203 | files = ( 204 | 3749319A1BF16D980087E39F /* BluetilityWindowController.swift in Sources */, 205 | 3749318D1BF169C90087E39F /* ViewController.swift in Sources */, 206 | 37F9CCD51C21D856003580CB /* PasteboardBrowser.swift in Sources */, 207 | 505C6E80233FC8C400ABC0A3 /* NSTextField+Shake.swift in Sources */, 208 | D89F71D724ACF67500861217 /* Swift+Extensions.swift in Sources */, 209 | 3769BDE41C32FB0D004C089D /* LogViewController.swift in Sources */, 210 | D8F204122D5088D900AAB8F8 /* CompanyIdentifiers.swift in Sources */, 211 | D89F265529A1C56300061958 /* BluetilityLogHandler.swift in Sources */, 212 | 3749319C1BF173470087E39F /* Scanner.swift in Sources */, 213 | 3749318B1BF169C90087E39F /* AppDelegate.swift in Sources */, 214 | D89F71D324ACF02800861217 /* Device.swift in Sources */, 215 | ); 216 | runOnlyForDeploymentPostprocessing = 0; 217 | }; 218 | /* End PBXSourcesBuildPhase section */ 219 | 220 | /* Begin PBXVariantGroup section */ 221 | 374931901BF169C90087E39F /* Main.storyboard */ = { 222 | isa = PBXVariantGroup; 223 | children = ( 224 | 374931911BF169C90087E39F /* Base */, 225 | ); 226 | name = Main.storyboard; 227 | sourceTree = ""; 228 | }; 229 | /* End PBXVariantGroup section */ 230 | 231 | /* Begin XCBuildConfiguration section */ 232 | 374931941BF169C90087E39F /* Debug */ = { 233 | isa = XCBuildConfiguration; 234 | baseConfigurationReference = D85908F22427E34700CBECC9 /* Debug.xcconfig */; 235 | buildSettings = { 236 | }; 237 | name = Debug; 238 | }; 239 | 374931951BF169C90087E39F /* Release */ = { 240 | isa = XCBuildConfiguration; 241 | baseConfigurationReference = D85908F32427E34700CBECC9 /* Release.xcconfig */; 242 | buildSettings = { 243 | }; 244 | name = Release; 245 | }; 246 | 374931971BF169C90087E39F /* Debug */ = { 247 | isa = XCBuildConfiguration; 248 | baseConfigurationReference = D85908F22427E34700CBECC9 /* Debug.xcconfig */; 249 | buildSettings = { 250 | CURRENT_PROJECT_VERSION = 11; 251 | MARKETING_VERSION = 1.6.0; 252 | }; 253 | name = Debug; 254 | }; 255 | 374931981BF169C90087E39F /* Release */ = { 256 | isa = XCBuildConfiguration; 257 | baseConfigurationReference = D85908F32427E34700CBECC9 /* Release.xcconfig */; 258 | buildSettings = { 259 | CURRENT_PROJECT_VERSION = 11; 260 | MARKETING_VERSION = 1.6.0; 261 | }; 262 | name = Release; 263 | }; 264 | /* End XCBuildConfiguration section */ 265 | 266 | /* Begin XCConfigurationList section */ 267 | 374931821BF169C90087E39F /* Build configuration list for PBXProject "Bluetility" */ = { 268 | isa = XCConfigurationList; 269 | buildConfigurations = ( 270 | 374931941BF169C90087E39F /* Debug */, 271 | 374931951BF169C90087E39F /* Release */, 272 | ); 273 | defaultConfigurationIsVisible = 0; 274 | defaultConfigurationName = Release; 275 | }; 276 | 374931961BF169C90087E39F /* Build configuration list for PBXNativeTarget "Bluetility" */ = { 277 | isa = XCConfigurationList; 278 | buildConfigurations = ( 279 | 374931971BF169C90087E39F /* Debug */, 280 | 374931981BF169C90087E39F /* Release */, 281 | ); 282 | defaultConfigurationIsVisible = 0; 283 | defaultConfigurationName = Release; 284 | }; 285 | /* End XCConfigurationList section */ 286 | 287 | /* Begin XCRemoteSwiftPackageReference section */ 288 | D89F264E29A1B5DF00061958 /* XCRemoteSwiftPackageReference "swift-log" */ = { 289 | isa = XCRemoteSwiftPackageReference; 290 | repositoryURL = "https://github.com/apple/swift-log.git"; 291 | requirement = { 292 | kind = upToNextMajorVersion; 293 | minimumVersion = 1.0.0; 294 | }; 295 | }; 296 | /* End XCRemoteSwiftPackageReference section */ 297 | 298 | /* Begin XCSwiftPackageProductDependency section */ 299 | D89F264F29A1B5DF00061958 /* Logging */ = { 300 | isa = XCSwiftPackageProductDependency; 301 | package = D89F264E29A1B5DF00061958 /* XCRemoteSwiftPackageReference "swift-log" */; 302 | productName = Logging; 303 | }; 304 | /* End XCSwiftPackageProductDependency section */ 305 | }; 306 | rootObject = 3749317F1BF169C90087E39F /* Project object */; 307 | } 308 | -------------------------------------------------------------------------------- /Bluetility/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Bluetility 4 | // 5 | // Created by Joseph Ross on 11/9/15. 6 | // Copyright © 2015 Joseph Ross. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import CoreBluetooth 11 | import Logging 12 | 13 | class ViewController: NSViewController { 14 | 15 | let scanner = Scanner() 16 | 17 | var recorder: LogRecorder? = nil 18 | let logger: Logger = Logger(label:"VC") 19 | 20 | var selectedDevice: Device? = nil 21 | var selectedService: CBService? = nil 22 | var selectedCharacteristic: CBCharacteristic? = nil 23 | var characteristicUpdatedDate: Date? = nil 24 | let dateFormatter = DateFormatter() 25 | var searchValue = "" 26 | 27 | @IBOutlet var browser: NSBrowser! 28 | @IBOutlet var writeAscii: NSTextField! 29 | @IBOutlet var writeHex: NSTextField! 30 | @IBOutlet var readButton: NSButton! 31 | @IBOutlet var subscribeButton: NSButton! 32 | @IBOutlet var statusLabel: NSTextField! 33 | 34 | var logWindowController: NSWindowController? = nil 35 | var logViewController: LogViewController? = nil 36 | var tooltipTagForRow: [Int:NSView.ToolTipTag] = [:] 37 | var rowForTooltipTag: [NSView.ToolTipTag:Int] = [:] 38 | 39 | 40 | override func viewDidLoad() { 41 | super.viewDidLoad() 42 | // Do any additional setup after loading the view. 43 | scanner.delegate = self 44 | scanner.start() 45 | dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss.SSS" 46 | browser.separatesColumns = false 47 | } 48 | 49 | override func viewWillAppear() { 50 | super.viewWillAppear() 51 | 52 | } 53 | 54 | override func viewDidLayout() { 55 | super.viewDidLayout() 56 | setupCharacteristicControls() 57 | } 58 | 59 | override func viewDidAppear() { 60 | super.viewDidAppear() 61 | } 62 | 63 | @IBAction func refreshPressed(_ sender: AnyObject?) { 64 | if let device = selectedDevice { 65 | device.disconnect() 66 | } 67 | selectedDevice = nil 68 | selectedService = nil 69 | scanner.restart() 70 | resetTooltips() 71 | } 72 | 73 | @IBAction func sortPressed(_ sender: AnyObject?) { 74 | scanner.devices.sort { return $0.rssi > $1.rssi } 75 | reloadColumn(0) 76 | } 77 | 78 | @IBAction func logPressed(_ sender: NSToolbarItem) { 79 | if let window = logWindowController?.window, window.isVisible || window.isMiniaturized { 80 | window.makeKeyAndOrderFront(self) 81 | } else { 82 | logWindowController?.window?.close() 83 | if let logWindowController = self.storyboard?.instantiateController(withIdentifier: "LogWindow") as? NSWindowController { 84 | logWindowController.shouldCascadeWindows = false 85 | logWindowController.window?.setFrameAutosaveName("bluetility_log") 86 | logWindowController.showWindow(sender) 87 | self.logWindowController = logWindowController 88 | self.logViewController = logWindowController.contentViewController as? LogViewController 89 | self.logViewController?.recorder = self.recorder 90 | } 91 | } 92 | } 93 | 94 | func searchTextDidChange(value: String) { 95 | searchValue = value.lowercased().trimmingCharacters(in: .whitespaces) 96 | resetTooltips() 97 | } 98 | 99 | func resetTooltips() { 100 | browser.removeAllToolTips() 101 | rowForTooltipTag = [:] 102 | tooltipTagForRow = [:] 103 | } 104 | } 105 | 106 | extension ViewController : NSBrowserDelegate { 107 | func filterBySearch(_ device: Device) -> Bool { 108 | if device.peripheral.identifier.uuidString.contains(searchValue) 109 | || device.friendlyName.lowercased().contains(searchValue) 110 | || device.companyIdentifier?.lowercased().contains(searchValue) ?? false 111 | || device.company?.lowercased().contains(searchValue) ?? false 112 | 113 | { 114 | return true 115 | } 116 | 117 | let advData = device.advertisingData 118 | 119 | if let serviceUUIDs = advData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID], containsInArrayOfCBUUIDs(searchFor: searchValue, inArray: serviceUUIDs){ 120 | return true 121 | } 122 | 123 | if let serviceUUIDS = advData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID], containsInArrayOfCBUUIDs(searchFor: searchValue, inArray: serviceUUIDS) { 124 | return true 125 | } 126 | 127 | return false 128 | } 129 | 130 | func containsInArrayOfCBUUIDs(searchFor: String, inArray: [CBUUID]) -> Bool { 131 | let asString = inArray.map {$0.uuidString} 132 | 133 | return asString.contains(where: { 134 | return $0.lowercased().contains(searchFor) 135 | }) 136 | } 137 | 138 | 139 | var visibleDevices: [Device] { 140 | if (searchValue == "") { 141 | return scanner.devices 142 | } 143 | 144 | return scanner.devices.filter(filterBySearch) 145 | } 146 | 147 | func browser(_ sender: NSBrowser, numberOfRowsInColumn column: Int) -> Int { 148 | switch column { 149 | case 0: return visibleDevices.count 150 | case 1: return selectedDevice?.services.count ?? 0 151 | case 2: return selectedService?.characteristics?.count ?? 0 152 | case 3: return 4 153 | default: return 0 154 | } 155 | } 156 | 157 | func browser(_ browser: NSBrowser, sizeToFitWidthOfColumn columnIndex: Int) -> CGFloat { 158 | return browser.width(ofColumn: columnIndex) 159 | } 160 | 161 | func browser(_ sender: NSBrowser, willDisplayCell cell: Any, atRow row: Int, column: Int) { 162 | guard let cell = cell as? NSBrowserCell else { return } 163 | switch column { 164 | case 0: 165 | let device = visibleDevices[row] 166 | cell.title = (device.friendlyName) + "(\(device.rssi))" 167 | let rect = browser.frame(ofRow: row, inColumn: column) 168 | if tooltipTagForRow[row] == nil { 169 | let tag = browser.addToolTip(rect, owner: self, userData: nil) 170 | tooltipTagForRow[row] = tag 171 | rowForTooltipTag[tag] = row 172 | } 173 | case 1: 174 | if let service = selectedDevice?.services[row] { 175 | cell.title = titleForUUID(service.uuid) 176 | } 177 | case 2: 178 | if let characteristic = selectedService?.characteristics?[row] { 179 | cell.title = titleForUUID(characteristic.uuid) 180 | } 181 | case 3: 182 | setupCharacteristicDetailCell(cell, forRow:row) 183 | default: 184 | break 185 | } 186 | } 187 | 188 | func titleForUUID(_ uuid:CBUUID) -> String { 189 | var title = uuid.description 190 | if (title.hasPrefix("Unknown")) { 191 | title = uuid.uuidString 192 | } 193 | return title 194 | } 195 | 196 | func browser(_ sender: NSBrowser, titleOfColumn column: Int) -> String? { 197 | switch column { 198 | case 0: return "Devices" 199 | case 1: return "Services" 200 | case 2: return "Characteristics" 201 | case 3: return "Detail" 202 | default: return "" 203 | } 204 | } 205 | 206 | func browser(_ browser: NSBrowser, shouldSizeColumn columnIndex: Int, forUserResize: Bool, toWidth suggestedWidth: CGFloat) -> CGFloat { 207 | if !forUserResize { 208 | if columnIndex == 0 { 209 | return 200 210 | } 211 | } 212 | return suggestedWidth 213 | } 214 | 215 | func selectDevice(_ device: Device) { 216 | if device != selectedDevice { 217 | selectedDevice?.disconnect() 218 | selectedDevice?.delegate = nil 219 | device.delegate = self 220 | device.connect() 221 | selectedDevice = device 222 | } 223 | } 224 | 225 | 226 | @IBAction 227 | func browserAction(_ sender:NSBrowser) { 228 | guard let indexPath = browser.selectionIndexPath else { return } 229 | let column = indexPath.count 230 | browser.setTitle(self.browser(browser,titleOfColumn:column)!, ofColumn: column) 231 | // Automatically reconnect if a service or characteristic is selected. 232 | if [2,3].contains(column) { 233 | reconnectPeripheral() 234 | } 235 | if column == 1 { 236 | let device = visibleDevices[indexPath[0]] 237 | if device != selectedDevice { 238 | updateStatusLabel(for: device) 239 | } 240 | selectDevice(device) 241 | reloadColumn(1) 242 | } else if column == 2 { 243 | if let service = selectedDevice?.services[safe: indexPath[1]] { 244 | selectedService = service 245 | selectedDevice?.discoverCharacteristics(for: service) 246 | selectedCharacteristic = nil 247 | reloadColumn(2) 248 | } 249 | } else if column == 3 { 250 | if let characteristic = selectedService?.characteristics?[indexPath[2]] { 251 | selectedCharacteristic = characteristic 252 | if characteristic.properties.contains(.read) { 253 | readCharacteristic() 254 | 255 | } 256 | refreshCharacteristicDetail() 257 | } 258 | } 259 | setupCharacteristicControls() 260 | } 261 | 262 | func reconnectPeripheral() { 263 | if let device = selectedDevice, device.peripheral.state != .connected { 264 | device.connect() 265 | } 266 | } 267 | 268 | func setupCharacteristicControls() { 269 | let minimumWidth = CGFloat(150) 270 | let frameWidth = view.frame.size.width 271 | var otherWidth = browser.width(ofColumn: 0) 272 | otherWidth += browser.width(ofColumn: 1) 273 | otherWidth += browser.width(ofColumn: 2) 274 | 275 | let fitWidth = frameWidth - otherWidth - 6 276 | if fitWidth >= minimumWidth { 277 | browser.setWidth(fitWidth, ofColumn: 3) 278 | browser.scrollColumnToVisible(0) 279 | } else { 280 | browser.setWidth(minimumWidth, ofColumn: 3) 281 | browser.scrollColumnToVisible(0) 282 | browser.scrollColumnToVisible(3) 283 | } 284 | 285 | readButton.removeFromSuperview() 286 | subscribeButton.removeFromSuperview() 287 | writeAscii.removeFromSuperview() 288 | writeHex.removeFromSuperview() 289 | if let characteristic = selectedCharacteristic { 290 | let frame = browser.frame(ofColumn: 3) 291 | if characteristic.properties.contains(.read) { 292 | browser.addSubview(readButton) 293 | readButton.frame = CGRect(x: frame.origin.x + (frame.size.width/2 - 42),y: frame.origin.y + 120,width: 84,height: 32) 294 | } 295 | if !characteristic.properties.intersection([.write, .writeWithoutResponse]).isEmpty { 296 | browser.addSubview(writeAscii) 297 | browser.addSubview(writeHex) 298 | writeHex.frame = CGRect(x: frame.origin.x + (frame.size.width/2 - 75),y: frame.origin.y + 80,width: 150,height: 22) 299 | writeAscii.frame = CGRect(x: frame.origin.x + (frame.size.width/2 - 75),y: frame.origin.y + 40,width: 150,height: 22) 300 | } 301 | if !characteristic.properties.intersection([.indicate, .notify]).isEmpty { 302 | browser.addSubview(subscribeButton) 303 | subscribeButton.frame = CGRect(x: frame.origin.x + (frame.size.width/2 - 45),y: frame.origin.y + 150,width: 90,height: 32) 304 | } 305 | } 306 | } 307 | 308 | func refreshCharacteristicDetail() { 309 | reloadColumn(3) 310 | } 311 | 312 | func reloadColumn(_ column: Int) { 313 | for row in 0.. String { 431 | if let row = rowForTooltipTag[tag] { 432 | let device = visibleDevices[row] 433 | return tooltip(for: device) 434 | } 435 | return "" 436 | } 437 | 438 | func tooltip(for device: Device) -> String { 439 | var tooltipParts:[String] = [] 440 | tooltipParts.append("\t\(device.friendlyName)") 441 | tooltipParts.append("identifier:\t\t\(device.peripheral.identifier)") 442 | if let company = device.company { 443 | tooltipParts.append("company:\t\t\(company)") 444 | } 445 | else if let companyIdentifier = device.companyIdentifier { 446 | tooltipParts.append("companyID:\t\(companyIdentifier)") 447 | } 448 | if let mfgData = device.advertisingData[CBAdvertisementDataManufacturerDataKey] as? Data { 449 | tooltipParts.append("Mfg Data:\t\t0x\(mfgData.hexString)") 450 | if mfgData[0] == 0xD9 && mfgData[1] == 0x01 { 451 | let uidData = mfgData[6..<14] 452 | tooltipParts.append("UID:\t\t\t\(uidData.hexString)") 453 | } 454 | } 455 | var allServiceUUIDs:[CBUUID] = [] 456 | if let serviceUUIDs = device.advertisingData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { 457 | allServiceUUIDs += serviceUUIDs 458 | } 459 | if let serviceUUIDs = device.advertisingData[CBAdvertisementDataOverflowServiceUUIDsKey] as? [CBUUID] { 460 | allServiceUUIDs += serviceUUIDs 461 | } 462 | if allServiceUUIDs.count > 0 { 463 | tooltipParts.append("Service UUIDs:\t\(allServiceUUIDs.map({return $0.uuidString}).joined(separator: ", "))") 464 | } 465 | if let txPower = device.advertisingData[CBAdvertisementDataTxPowerLevelKey] as? NSNumber { 466 | tooltipParts.append("Tx Power:\t\t\(txPower)") 467 | } 468 | 469 | return tooltipParts.joined(separator: "\n") 470 | } 471 | } 472 | 473 | extension ViewController : PasteboardBrowserDelegate { 474 | func browser(_ browser: PasteboardBrowser, pasteboardStringFor indexPath: IndexPath) -> String? { 475 | if indexPath.count == 1 { 476 | let row = indexPath[0] 477 | guard let tag = tooltipTagForRow[row] else {return nil} 478 | let peripheralName = visibleDevices[row].friendlyName 479 | return "Name:\t\t\t\(peripheralName)\n" + self.view(browser, stringForToolTip: tag, point: NSPoint(), userData: nil) 480 | } 481 | return nil 482 | } 483 | 484 | func browser(_ browser: PasteboardBrowser, menuFor cell: NSBrowserCell, atRow row: Int, column: Int) -> NSMenu? { 485 | let menu = NSMenu() 486 | let copyItem = NSMenuItem(title: "Copy", action: #selector(copyCellContents(sender:)), keyEquivalent: "") 487 | menu.addItem(copyItem) 488 | 489 | if column == 0 { 490 | menu.addItem(NSMenuItem(title: "Copy Device Identifier", action: #selector(copyDeviceIdentifier(sender:)), keyEquivalent: "")) 491 | menu.addItem(NSMenuItem(title: "Copy Device Tooltip", action: #selector(copyDeviceTooltip(sender:)), keyEquivalent: "")) 492 | } else if column == 1 { 493 | menu.addItem(NSMenuItem(title: "Copy Service UUID", action: #selector(copyServiceUUID(sender:)), keyEquivalent: "")) 494 | } else if column == 2 { 495 | menu.addItem(NSMenuItem(title: "Copy Characteristic UUID", action: #selector(copyCharacteristicUUID(sender:)), keyEquivalent: "")) 496 | } 497 | 498 | return menu 499 | } 500 | 501 | @IBAction 502 | func copyCellContents(sender: Any) { 503 | guard let cell = browser.selectedCell() as? NSBrowserCell else { return } 504 | 505 | putPasteboardString(cell.title) 506 | } 507 | 508 | @IBAction 509 | func copyDeviceIdentifier(sender: Any) { 510 | guard let deviceIdentifier = selectedDevice?.peripheral.identifier else { return } 511 | 512 | putPasteboardString(deviceIdentifier.uuidString) 513 | } 514 | 515 | @IBAction 516 | func copyDeviceTooltip(sender: Any) { 517 | guard let device = selectedDevice else { return } 518 | 519 | putPasteboardString(tooltip(for: device)) 520 | } 521 | 522 | @IBAction 523 | func copyServiceUUID(sender: Any) { 524 | guard let serviceUUID = selectedService?.uuid else { return } 525 | 526 | putPasteboardString(serviceUUID.uuidString) 527 | } 528 | 529 | @IBAction 530 | func copyCharacteristicUUID(sender: Any) { 531 | guard let characteristicUUID = selectedCharacteristic?.uuid else { return } 532 | 533 | putPasteboardString(characteristicUUID.uuidString) 534 | } 535 | 536 | 537 | private func putPasteboardString(_ string: String) { 538 | let pb = NSPasteboard.general 539 | pb.declareTypes([.string], owner: self) 540 | pb.setString(string, forType: .string) 541 | } 542 | } 543 | 544 | extension ViewController : ScannerDelegate { 545 | func scanner(_ scanner: Scanner, didUpdateDevices: [Device]) { 546 | reloadColumn(0) 547 | } 548 | } 549 | 550 | extension ViewController : DeviceDelegate { 551 | func deviceDidConnect(_ device: Device) { 552 | updateStatusLabel(for: device) 553 | } 554 | 555 | func deviceDidDisconnect(_ device: Device) { 556 | updateStatusLabel(for: device) 557 | } 558 | 559 | func deviceDidUpdateName(_ device: Device) { 560 | updateStatusLabel(for: device) 561 | guard let deviceIndex = visibleDevices.firstIndex(of: device) else { return } 562 | browser.reloadData(forRowIndexes: IndexSet(integer: deviceIndex), inColumn: 0) 563 | } 564 | 565 | func device(_ device: Device, updated services: [CBService]) { 566 | reloadColumn(1) 567 | if browser.selectionIndexPath?.count == 2 { 568 | browserAction(browser) 569 | } else { 570 | for service in services { 571 | if service.uuid == selectedService?.uuid { 572 | selectedService = service 573 | device.discoverCharacteristics(for: service) 574 | } 575 | } 576 | } 577 | } 578 | 579 | func device(_ device: Device, updated characteristics: [CBCharacteristic], for service: CBService) { 580 | reloadColumn(2) 581 | if browser.selectionIndexPath?.count == 3 { 582 | browserAction(browser) 583 | } else { 584 | for characteristic in characteristics { 585 | if characteristic.uuid == selectedCharacteristic?.uuid { 586 | selectedCharacteristic = characteristic 587 | if characteristic.properties.contains(.read) { 588 | device.read(characteristic: characteristic) 589 | 590 | } 591 | refreshCharacteristicDetail() 592 | } 593 | } 594 | } 595 | } 596 | 597 | func device(_ device: Device, updatedValueFor characteristic: CBCharacteristic) { 598 | if characteristic == selectedCharacteristic { 599 | characteristicUpdatedDate = Date() 600 | refreshCharacteristicDetail() 601 | } 602 | } 603 | } 604 | -------------------------------------------------------------------------------- /Bluetility/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 461 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | --------------------------------------------------------------------------------