├── .clang-format ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── blueutil.m ├── blueutil.xcodeproj └── project.pbxproj ├── blueutil_Prefix.pch ├── release ├── test ├── update_usage └── verify_release /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: ObjC 3 | BasedOnStyle: google 4 | 5 | AlignAfterOpenBracket: DontAlign 6 | AlignOperands: false 7 | AllowAllArgumentsOnNextLine: false 8 | AllowShortFunctionsOnASingleLine: Inline 9 | BinPackArguments: false 10 | BreakStringLiterals: false 11 | ColumnLimit: 120 12 | ConstructorInitializerIndentWidth: 2 13 | ContinuationIndentWidth: 2 14 | NamespaceIndentation: All 15 | ObjCSpaceAfterProperty: true 16 | SpacesInContainerLiterals: false 17 | TabWidth: 2 18 | ... 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -crlf -merge 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /blueutil 2 | 3 | /pkg/ 4 | 5 | build/ 6 | *.pbxuser 7 | *.mode1v3 8 | *.mode2v3 9 | *.perspective 10 | *.perspectivev3 11 | project.xcworkspace/ 12 | xcuserdata/ 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## unreleased 4 | 5 | ## v2.12.0 (2025-02-02) 6 | 7 | * Hide debug log messages from IOBluetoothDeviceInquiry [@toy](https://github.com/toy) 8 | 9 | ## v2.11.0 (2025-01-18) 10 | 11 | * Inform the user on receiving abort signal, that it may be due to absence of permission [#95](https://github.com/toy/blueutil/issues/95) [@toy](https://github.com/toy) 12 | 13 | ## v2.10.0 (2024-04-28) 14 | 15 | * Document macOS >= 12 silently ignoring favourites and recent access date [#63](https://github.com/toy/blueutil/issues/63) [#84](https://github.com/toy/blueutil/issues/84) [#89](https://github.com/toy/blueutil/issues/89) [@toy](https://github.com/toy) 16 | * In addition to recent devices, paired devices will also be searched when connect, disconnect, get information about, and check connected state of device by name [#62](https://github.com/toy/blueutil/issues/62) [#88](https://github.com/toy/blueutil/pull/88) [@azuwis](https://github.com/azuwis) 17 | * Fix device inquiry not finishing in Monterey and later [#66](https://github.com/toy/blueutil/pull/66) [@RaphaelKn](https://github.com/RaphaelKn) 18 | 19 | ## v2.9.1 (2023-01-14) 20 | 21 | * When disconnecting, explicitly wait for device disconnection [#70](https://github.com/toy/blueutil/issues/70) [@toy](https://github.com/toy) 22 | 23 | ## v2.9.0 (2021-05-23) 24 | 25 | * Add unpairing/removing pairing functionality [#27](https://github.com/toy/blueutil/issues/27) [#31](https://github.com/toy/blueutil/issues/31) [#32](https://github.com/toy/blueutil/issues/32) [#46](https://github.com/toy/blueutil/issues/46) [#53](https://github.com/toy/blueutil/issues/53) [@toy](https://github.com/toy) 26 | * Remove experimental mark from different failure exit codes [@toy](https://github.com/toy) 27 | 28 | ## v2.8.0 (2021-03-21) 29 | 30 | * Print a warning when no power when running connect, disconnect, inquiry and pair commands [#50](https://github.com/toy/blueutil/issues/50) [@toy](https://github.com/toy) 31 | * Fix check for RSSI equality when waiting for device [@toy](https://github.com/toy) 32 | * Support US spelling favorite for switches including favourite [#49](https://github.com/toy/blueutil/issues/49) [@toy](https://github.com/toy) 33 | * List connected devices [#47](https://github.com/toy/blueutil/issues/47) [@toy](https://github.com/toy) 34 | 35 | ## v2.7.0 (2020-11-17) 36 | 37 | * Refuse to run as root user to prevent possible issues like discoverability getting stuck in some state [#41](https://github.com/toy/blueutil/issues/41) [@toy](https://github.com/toy) 38 | 39 | ## v2.6.0 (2020-03-25) 40 | 41 | * Show underlying regex error messages in output, use default out of memory message [@toy](https://github.com/toy) 42 | * Experimental use different failure exit codes from sysexits [@toy](https://github.com/toy) 43 | * Add changelog [@toy](https://github.com/toy) 44 | * Internal change to use blocks instead of going two times through options [@toy](https://github.com/toy) 45 | * Mention in usage that requesting 0 recent devices will list all of them [@toy](https://github.com/toy) 46 | * Introduce clang-format (required converting tabs to spaces) [@toy](https://github.com/toy) 47 | * Experimental functionality to wait for device to connect, disconnect or for its RSSI to match expectation [@toy](https://github.com/toy) 48 | * Fix probable leaks by using autoreleasepool in few places [@toy](https://github.com/toy) 49 | * Add ability to add/remove favourites [#29](https://github.com/toy/blueutil/issues/29) [@toy](https://github.com/toy) 50 | * Add instructions to update/uninstall [#28](https://github.com/toy/blueutil/issues/28) [@toy](https://github.com/toy) 51 | 52 | ## v2.5.1 (2019-08-27) 53 | 54 | * Use last specified format for all output commands [#25](https://github.com/toy/blueutil/issues/25) [@toy](https://github.com/toy) 55 | * Handle null for name and recent access date to fix an error for json output and ugly output in other formatters [#24](https://github.com/toy/blueutil/issues/24) [@toy](https://github.com/toy) 56 | 57 | ## v2.5.0 (2019-08-21) 58 | 59 | * Allow switching default formatter to json, json-pretty and new-default (comma separated key-value pairs) [#17](https://github.com/toy/blueutil/issues/17) [@toy](https://github.com/toy) 60 | * Add instructions to install from [MacPorts](https://www.macports.org/) [@toy](https://github.com/toy) 61 | * Specify 10.9 as the minimum version explicitly [#16](https://github.com/toy/blueutil/issues/16) [@toy](https://github.com/toy) 62 | 63 | ## v2.4.0 (2019-01-25) 64 | 65 | * Change license to MIT with [permission from Frederik Seiffert](https://github.com/toy/blueutil/issues/14#issuecomment-455985947) [#14](https://github.com/toy/blueutil/issues/14) [#15](https://github.com/toy/blueutil/pull/15) [@toy](https://github.com/toy) 66 | 67 | ## v2.3.0 (2019-01-14) 68 | 69 | * Add pairing functionality [#13](https://github.com/toy/blueutil/issues/13) [@toy](https://github.com/toy) 70 | * Add headings and install instructions to README [#11](https://github.com/toy/blueutil/pull/11) [@friedrichweise](https://github.com/friedrichweise) 71 | 72 | ## v2.2.0 (2018-10-11) 73 | 74 | * Add ability to connect, disconnect, get information about, and check connected state of device by address or name from the list of recent devices [mentioned in #9](https://github.com/toy/blueutil/issues/9) [@toy](https://github.com/toy) 75 | * Add inquiring devices in range and listing favourite, paired and recent devices [#9](https://github.com/toy/blueutil/issues/9) [@toy](https://github.com/toy) 76 | * Fix missing newline after message about unexpected state value [@toy](https://github.com/toy) 77 | * Set deployment target to 10.6 [@toy](https://github.com/toy) 78 | 79 | ## v2.1.0 (2018-04-19) 80 | 81 | * Add ability to toggle power and discoverability state [#8](https://github.com/toy/blueutil/issues/8) [@toy](https://github.com/toy) 82 | * Add note about effect of opening bluetooth preference pane on discoverability [suggested in #3](https://github.com/toy/blueutil/issues/3) [@toy](https://github.com/toy) 83 | * Update xcode project to compatibility version 3.2 [missing part of #7](https://github.com/toy/blueutil/issues/7) [@toy](https://github.com/toy) 84 | 85 | ## v2.0.0 (2018-02-18) 86 | 87 | * Change arguments specification to Unix/POSIX style [#7](https://github.com/toy/blueutil/issues/7) [@toy](https://github.com/toy) 88 | * Don’t show the WARNING when piping yes to the test script [@toy](https://github.com/toy) 89 | * Make error message for discoverable consistent [@toy](https://github.com/toy) 90 | * Run make install/uninstall commands instead of only printing them [@toy](https://github.com/toy) 91 | 92 | ## v1.1.2 (2017-02-04) 93 | 94 | * Add a warning and confirmation to the test script for users of wireless input devices [#6](https://github.com/toy/blueutil/issues/6) [@toy](https://github.com/toy) 95 | * Add proper make targets: build (default), test, clean, install and uninstall [@toy](https://github.com/toy) 96 | * Fix wrong handling of length in is_abbr_arg [#6](https://github.com/toy/blueutil/issues/6) [@toy](https://github.com/toy) 97 | 98 | ## v1.1.0 (2017-02-01) 99 | 100 | * Add basic makefile as an alternative to using xcode [@toy](https://github.com/toy) 101 | * Add simple test script for getting/setting power/discoverability [@toy](https://github.com/toy) 102 | * Allow abbreviating help, version and status commands [@toy](https://github.com/toy) 103 | * Add version command [@toy](https://github.com/toy) 104 | * Restore waiting for state to change after setting it, check every 0.1 second for 10 seconds [@toy](https://github.com/toy) 105 | * Add help command [@toy](https://github.com/toy) 106 | * Restore original style arguments: status, on, off [#4](https://github.com/toy/blueutil/issues/4) [@toy](https://github.com/toy) 107 | * Allow abbreviating power and discoverable arguments [@toy](https://github.com/toy) 108 | 109 | ## v1.0.0 (2012-02-26) 110 | 111 | * Switch to unconditionally waiting 1 second after setting value as waiting for result to change was not working [@toy](https://github.com/toy) 112 | * Allow getting and setting discoverable state alongside power state, use 1/0 instead of on/off [@toy](https://github.com/toy) 113 | * Import original code by Frederik Seiffert [@triplef](https://github.com/triplef) from http://frederikseiffert.de/blueutil 114 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Originally written by Frederik Seiffert 2 | 3 | Copyright (c) 2011-2025 Ivan Kuchin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CFLAGS = -Wall -Wextra -Werror -mmacosx-version-min=10.9 -framework Foundation -framework IOBluetooth 2 | 3 | DESTDIR = 4 | prefix = /usr/local 5 | bindir = $(prefix)/bin 6 | INSTALL = install 7 | INSTALL_PROGRAM = $(INSTALL) -m 755 8 | 9 | all: build 10 | 11 | build: blueutil 12 | 13 | format: 14 | clang-format -i *.m 15 | 16 | update_usage: blueutil 17 | ./update_usage 18 | touch update_usage 19 | 20 | test: build 21 | ./test 22 | 23 | clean: 24 | $(RM) blueutil 25 | 26 | install: build 27 | $(INSTALL_PROGRAM) blueutil $(DESTDIR)$(bindir)/blueutil 28 | 29 | uninstall: 30 | $(RM) $(DESTDIR)$(bindir)/blueutil 31 | 32 | .PHONY: all build format test clean install uninstall 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blueutil 2 | 3 | CLI for bluetooth on OSX: power, discoverable state, list, inquire devices, connect, info, … 4 | 5 | ## Notes 6 | 7 | Uses private API from IOBluetooth framework (i.e. `IOBluetoothPreference*()`). 8 | 9 | Opening Bluetooth preference pane always turns on discoverability if bluetooth power is on or if it is switched on when preference pane is open, this change of state is not reported by the function used by `blueutil`. 10 | 11 | ## Usage 12 | 13 | 14 | ``` 15 | Usage: 16 | blueutil [options] 17 | 18 | Without options outputs current state 19 | 20 | -p, --power output power state as 1 or 0 21 | -p, --power STATE set power state 22 | -d, --discoverable output discoverable state as 1 or 0 23 | -d, --discoverable STATE set discoverable state 24 | 25 | --favourites, --favorites 26 | list favourite devices; returns empty list starting with macOS 12/Monterey 27 | --inquiry [T] inquiry devices in range, 10 seconds duration by default excluding time for name updates 28 | --paired list paired devices 29 | --recent [N] list recently used devices, 10 by default, 0 to list all; returns empty list starting with macOS 12/Monterey 30 | --connected list connected devices 31 | 32 | --info ID show information about device 33 | --is-connected ID connected state of device as 1 or 0 34 | --connect ID create a connection to device 35 | --disconnect ID close the connection to device 36 | --pair ID [PIN] pair with device, optional PIN of up to 16 characters will be used instead of interactive input if requested in specific pair mode 37 | --unpair ID EXPERIMENTAL unpair the device 38 | --add-favourite ID, --add-favorite ID 39 | add to favourites; does nothing starting with macOS 12/Monterey 40 | --remove-favourite ID, --remove-favorite ID 41 | remove from favourites; does nothing starting with macOS 12/Monterey 42 | 43 | --format FORMAT change output format of info and all listing commands 44 | 45 | --wait-connect ID [TIMEOUT] 46 | EXPERIMENTAL wait for device to connect 47 | --wait-disconnect ID [TIMEOUT] 48 | EXPERIMENTAL wait for device to disconnect 49 | --wait-rssi ID OP VALUE [PERIOD [TIMEOUT]] 50 | EXPERIMENTAL wait for device RSSI value which is 0 for golden range, -129 if it cannot be read (e.g. device is disconnected) 51 | 52 | -h, --help this help 53 | -v, --version show version 54 | 55 | STATE can be one of: 1, on, 0, off, toggle 56 | ID can be either address in form xxxxxxxxxxxx, xx-xx-xx-xx-xx-xx or xx:xx:xx:xx:xx:xx, or name of device to search in paired or recent devices 57 | OP can be one of: >, >=, <, <=, =, !=; or equivalents: gt, ge, lt, le, eq, ne 58 | PERIOD is in seconds, defaults to 1 59 | TIMEOUT is in seconds, default value 0 doesn't add timeout 60 | FORMAT can be one of: 61 | default - human readable text output not intended for consumption by scripts 62 | new-default - human readable comma separated key-value pairs (EXPERIMENTAL, THE BEHAVIOUR MAY CHANGE) 63 | json - compact JSON 64 | json-pretty - pretty printed JSON 65 | 66 | Favourite devices and recent access date are not stored starting with macOS 12/Monterey, current time is returned for recent access date by framework instead. 67 | 68 | Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41). 69 | Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …). 70 | 71 | Exit codes: 72 | 0 Success 73 | 1 General failure 74 | 64 Wrong usage like missing or unexpected arguments, wrong parameters 75 | 69 Bluetooth or interface not available 76 | 70 Internal error 77 | 71 System error like shortage of memory 78 | 75 Timeout error 79 | 134 Abort signal may indicate absence of access to Bluetooth API 80 | ``` 81 | 82 | 83 | ## Install/update/uninstall 84 | 85 | ### Homebrew 86 | 87 | Using package manager [Homebrew](https://brew.sh/): 88 | 89 | ```sh 90 | # install 91 | brew install blueutil 92 | 93 | # update 94 | brew update 95 | brew upgrade blueutil 96 | 97 | # uninstall 98 | brew remove blueutil 99 | ``` 100 | 101 | ### MacPorts 102 | 103 | Using package manager [MacPorts](https://www.macports.org/): 104 | 105 | ```sh 106 | # install 107 | port install blueutil 108 | 109 | # update 110 | port selfupdate 111 | port upgrade blueutil 112 | 113 | # uninstall 114 | port uninstall blueutil 115 | ``` 116 | 117 | You will probably need to prefix all commands with `sudo`. 118 | 119 | ### From source 120 | 121 | ```sh 122 | git clone https://github.com/toy/blueutil.git 123 | cd blueutil 124 | 125 | # build 126 | make 127 | 128 | # install/update 129 | git pull 130 | make install 131 | 132 | # uninstall 133 | make uninstall 134 | ``` 135 | 136 | You may need to prefix install/update and uninstall make commands with `sudo`. 137 | 138 | ## Alternative Interface 139 | For a TUI (text-based user interface) build on top of `blueutil`, you can take a look at [blueutil-tui](https://github.com/Zaloog/blueutil-tui). 140 | It offers a simple interface for the following `blueutil` functionalities: 141 | - displaying paired devices 142 | - searching devices 143 | - pairing and unpairing devices 144 | - connecting and disconnecting devices 145 | 146 | It's written in python using the [textual](https://textual.textualize.io) framework. 147 | 148 | ## Development 149 | 150 | To build and update usage: 151 | 152 | ```sh 153 | make build update_usage 154 | ``` 155 | 156 | To apply clang-format: 157 | 158 | ```sh 159 | make format 160 | ``` 161 | 162 | To test: 163 | 164 | ```sh 165 | make test 166 | ``` 167 | 168 | To release new version: 169 | 170 | ```sh 171 | ./release major|minor|patch 172 | ``` 173 | 174 | To create release on github: 175 | 176 | ```sh 177 | ./verify_release 178 | ``` 179 | 180 | If there are no validation errors, copy generated markdown to description of new release: 181 | 182 | ```sh 183 | open "https://github.com/toy/blueutil/releases/new?tag=$(git describe --tags --abbrev=0)" 184 | ``` 185 | 186 | ## Copyright 187 | 188 | Originally written by Frederik Seiffert ego@frederikseiffert.de http://www.frederikseiffert.de/blueutil/ 189 | 190 | Copyright (c) 2011-2025 Ivan Kuchin. See [LICENSE.txt](LICENSE.txt) for details. 191 | -------------------------------------------------------------------------------- /blueutil.m: -------------------------------------------------------------------------------- 1 | // blueutil 2 | // 3 | // CLI for bluetooth on OSX: power, discoverable state, list, inquire devices, connect, info, … 4 | // Uses private API from IOBluetooth framework (i.e. IOBluetoothPreference*()). 5 | // https://github.com/toy/blueutil 6 | // 7 | // Originally written by Frederik Seiffert http://www.frederikseiffert.de/blueutil/ 8 | // 9 | // Copyright (c) 2011-2025 Ivan Kuchin. See for details. 10 | 11 | #define VERSION "2.12.0" 12 | 13 | #import 14 | 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #define EX_SIGABRT (128 + SIGABRT) 21 | 22 | #define eprintf(...) fprintf(stderr, ##__VA_ARGS__) 23 | 24 | void *assert_alloc(void *pointer) { 25 | if (pointer == NULL) { 26 | eprintf("%s\n", strerror(errno)); 27 | exit(EX_OSERR); 28 | } 29 | return pointer; 30 | } 31 | 32 | int assert_reg(int errcode, const regex_t *restrict preg, char *reason) { 33 | if (errcode == 0 || errcode == REG_NOMATCH) return errcode; 34 | 35 | size_t errbuf_size = regerror(errcode, preg, NULL, 0); 36 | char *restrict errbuf = assert_alloc(malloc(errbuf_size)); 37 | regerror(errcode, preg, errbuf, errbuf_size); 38 | 39 | eprintf("%s: %s\n", reason, errbuf); 40 | exit(EX_SOFTWARE); 41 | } 42 | 43 | // private methods 44 | int IOBluetoothPreferencesAvailable(); 45 | 46 | int IOBluetoothPreferenceGetControllerPowerState(); 47 | void IOBluetoothPreferenceSetControllerPowerState(int state); 48 | 49 | int IOBluetoothPreferenceGetDiscoverableState(); 50 | void IOBluetoothPreferenceSetDiscoverableState(int state); 51 | 52 | void _NSSetLogCStringFunction(void(*)(const char*, unsigned, BOOL)); 53 | 54 | // short names 55 | typedef int (*GetterFunc)(); 56 | typedef bool (*SetterFunc)(int); 57 | 58 | enum state { 59 | toggle = -1, 60 | off = 0, 61 | on = 1, 62 | }; 63 | 64 | bool BTSetParamState(enum state state, GetterFunc getter, void (*setter)(int), const char *name) { 65 | if (state == toggle) state = !getter(); 66 | 67 | if (state == getter()) return true; 68 | 69 | setter(state); 70 | 71 | for (int i = 0; i <= 100; i++) { 72 | if (i) usleep(100000); 73 | if (state == getter()) return true; 74 | } 75 | 76 | eprintf("Failed to switch bluetooth %s %s in 10 seconds\n", name, state ? "on" : "off"); 77 | return false; 78 | } 79 | 80 | #define BTAvaliable IOBluetoothPreferencesAvailable 81 | 82 | #define BTPowerState IOBluetoothPreferenceGetControllerPowerState 83 | bool BTSetPowerState(enum state state) { 84 | return BTSetParamState(state, BTPowerState, IOBluetoothPreferenceSetControllerPowerState, "power"); 85 | } 86 | 87 | #define BTDiscoverableState IOBluetoothPreferenceGetDiscoverableState 88 | bool BTSetDiscoverableState(enum state state) { 89 | return BTSetParamState(state, BTDiscoverableState, IOBluetoothPreferenceSetDiscoverableState, "discoverable"); 90 | } 91 | 92 | void check_power_on_for(const char *command) { 93 | if (BTPowerState()) return; 94 | eprintf("Power is required to be on for %s command\n", command); 95 | } 96 | 97 | const char *filter_out_ns_log[] = { 98 | "-[IOBluetoothDeviceInquiry initWithDelegate:]", 99 | "-[IOBluetoothDeviceInquiry dealloc]", 100 | }; 101 | 102 | void CustomNSLogOutput(const char* message, __unused unsigned length, __unused BOOL withSysLogBanner) { 103 | for (size_t i = 0, _i = sizeof(filter_out_ns_log) / sizeof(filter_out_ns_log[0]); i < _i; i++) { 104 | if (strstr(message, filter_out_ns_log[i]) != NULL) return; 105 | } 106 | 107 | eprintf("%s\n", message); 108 | } 109 | 110 | void usage(FILE *io) { 111 | static const char *lines[] = { 112 | ("blueutil v" VERSION), 113 | "", 114 | "Usage:", 115 | " blueutil [options]", 116 | "", 117 | "Without options outputs current state", 118 | "", 119 | " -p, --power output power state as 1 or 0", 120 | " -p, --power STATE set power state", 121 | " -d, --discoverable output discoverable state as 1 or 0", 122 | " -d, --discoverable STATE set discoverable state", 123 | "", 124 | " --favourites, --favorites", 125 | " list favourite devices; returns empty list starting with macOS 12/Monterey", 126 | " --inquiry [T] inquiry devices in range, 10 seconds duration by default excluding time for name updates", 127 | " --paired list paired devices", 128 | " --recent [N] list recently used devices, 10 by default, 0 to list all; returns empty list starting with macOS 12/Monterey", 129 | " --connected list connected devices", 130 | "", 131 | " --info ID show information about device", 132 | " --is-connected ID connected state of device as 1 or 0", 133 | " --connect ID create a connection to device", 134 | " --disconnect ID close the connection to device", 135 | " --pair ID [PIN] pair with device, optional PIN of up to 16 characters will be used instead of interactive input if requested in specific pair mode", 136 | " --unpair ID EXPERIMENTAL unpair the device", 137 | " --add-favourite ID, --add-favorite ID", 138 | " add to favourites; does nothing starting with macOS 12/Monterey", 139 | " --remove-favourite ID, --remove-favorite ID", 140 | " remove from favourites; does nothing starting with macOS 12/Monterey", 141 | "", 142 | " --format FORMAT change output format of info and all listing commands", 143 | "", 144 | " --wait-connect ID [TIMEOUT]", 145 | " EXPERIMENTAL wait for device to connect", 146 | " --wait-disconnect ID [TIMEOUT]", 147 | " EXPERIMENTAL wait for device to disconnect", 148 | " --wait-rssi ID OP VALUE [PERIOD [TIMEOUT]]", 149 | " EXPERIMENTAL wait for device RSSI value which is 0 for golden range, -129 if it cannot be read (e.g. device is disconnected)", 150 | "", 151 | " -h, --help this help", 152 | " -v, --version show version", 153 | "", 154 | "STATE can be one of: 1, on, 0, off, toggle", 155 | "ID can be either address in form xxxxxxxxxxxx, xx-xx-xx-xx-xx-xx or xx:xx:xx:xx:xx:xx, or name of device to search in paired or recent devices", 156 | "OP can be one of: >, >=, <, <=, =, !=; or equivalents: gt, ge, lt, le, eq, ne", 157 | "PERIOD is in seconds, defaults to 1", 158 | "TIMEOUT is in seconds, default value 0 doesn't add timeout", 159 | "FORMAT can be one of:", 160 | " default - human readable text output not intended for consumption by scripts", 161 | " new-default - human readable comma separated key-value pairs (EXPERIMENTAL, THE BEHAVIOUR MAY CHANGE)", 162 | " json - compact JSON", 163 | " json-pretty - pretty printed JSON", 164 | "", 165 | "Favourite devices and recent access date are not stored starting with macOS 12/Monterey, current time is returned for recent access date by framework instead.", 166 | "", 167 | "Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41).", 168 | "Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …).", 169 | "", 170 | "Exit codes:", 171 | }; 172 | 173 | for (size_t i = 0, _i = sizeof(lines) / sizeof(lines[0]); i < _i; i++) { 174 | fprintf(io, "%s\n", lines[i]); 175 | } 176 | 177 | struct exit_code { 178 | unsigned char code; 179 | const char *description; 180 | }; 181 | 182 | struct exit_code exit_codes[] = { 183 | {EXIT_SUCCESS, "Success"}, 184 | {EXIT_FAILURE, "General failure"}, 185 | {EX_USAGE, "Wrong usage like missing or unexpected arguments, wrong parameters"}, 186 | {EX_UNAVAILABLE, "Bluetooth or interface not available"}, 187 | {EX_SOFTWARE, "Internal error"}, 188 | {EX_OSERR, "System error like shortage of memory"}, 189 | {EX_TEMPFAIL, "Timeout error"}, 190 | {EX_SIGABRT, "Abort signal may indicate absence of access to Bluetooth API"}, 191 | }; 192 | 193 | for (size_t i = 0, _i = sizeof(exit_codes) / sizeof(exit_codes[0]); i < _i; i++) { 194 | fprintf(io, " %3d %s\n", exit_codes[i].code, exit_codes[i].description); 195 | } 196 | } 197 | 198 | void handle_abort(__unused int signal) { 199 | eprintf("Error: Received abort signal, it may be due to absence of access to Bluetooth API, check that current " 200 | "terminal application has access in System Settings > Privacy & Security > Bluetooth\n"); 201 | 202 | // keep the exit code 203 | exit(EX_SIGABRT); 204 | } 205 | 206 | char *next_arg(int argc, char *argv[], bool required) { 207 | if (optind < argc && NULL != argv[optind] && (required || '-' != argv[optind][0])) { 208 | return argv[optind++]; 209 | } else { 210 | return NULL; 211 | } 212 | } 213 | 214 | char *next_reqarg(int argc, char *argv[]) { 215 | return next_arg(argc, argv, true); 216 | } 217 | 218 | char *next_optarg(int argc, char *argv[]) { 219 | return next_arg(argc, argv, false); 220 | } 221 | 222 | // getopt_long doesn't consume optional argument separated by space 223 | // https://stackoverflow.com/a/32575314 224 | void extend_optarg(int argc, char *argv[]) { 225 | if (!optarg) optarg = next_optarg(argc, argv); 226 | } 227 | 228 | bool parse_state_arg(char *arg, enum state *state) { 229 | if (0 == strcmp(arg, "1") || 0 == strcasecmp(arg, "on")) { 230 | *state = on; 231 | return true; 232 | } 233 | 234 | if (0 == strcmp(arg, "0") || 0 == strcasecmp(arg, "off")) { 235 | *state = off; 236 | return true; 237 | } 238 | 239 | if (0 == strcasecmp(arg, "toggle")) { 240 | *state = toggle; 241 | return true; 242 | } 243 | 244 | return false; 245 | } 246 | 247 | bool check_device_address_arg(char *arg) { 248 | regex_t regex; 249 | int result; 250 | 251 | result = regcomp(®ex, 252 | "^[0-9a-f]{2}([0-9a-f]{10}|(-[0-9a-f]{2}){5}|(:[0-9a-f]{2}){5})$", 253 | REG_EXTENDED | REG_ICASE | REG_NOSUB); 254 | assert_reg(result, ®ex, "Compiling device address regex"); 255 | 256 | result = regexec(®ex, arg, 0, NULL, 0); 257 | assert_reg(result, ®ex, "Matching device address regex"); 258 | 259 | regfree(®ex); 260 | 261 | return result == 0; 262 | } 263 | 264 | bool parse_unsigned_long_arg(char *arg, unsigned long *number) { 265 | regex_t regex; 266 | int result; 267 | 268 | result = regcomp(®ex, "^[[:digit:]]+$", REG_EXTENDED | REG_NOSUB); 269 | assert_reg(result, ®ex, "Compiling number regex"); 270 | 271 | result = regexec(®ex, arg, 0, NULL, 0); 272 | assert_reg(result, ®ex, "Matching number regex"); 273 | 274 | regfree(®ex); 275 | 276 | if (result == 0) { 277 | *number = strtoul(arg, NULL, 10); 278 | return true; 279 | } else { 280 | return false; 281 | } 282 | } 283 | 284 | bool parse_signed_long_arg(char *arg, long *number) { 285 | regex_t regex; 286 | int result; 287 | 288 | result = regcomp(®ex, "^-?[[:digit:]]+$", REG_EXTENDED | REG_NOSUB); 289 | assert_reg(result, ®ex, "Compiling number regex"); 290 | 291 | result = regexec(®ex, arg, 0, NULL, 0); 292 | assert_reg(result, ®ex, "Matching number regex"); 293 | 294 | regfree(®ex); 295 | 296 | if (result == 0) { 297 | *number = strtol(arg, NULL, 10); 298 | return true; 299 | } else { 300 | return false; 301 | } 302 | } 303 | 304 | IOBluetoothDevice *get_device(char *id) { 305 | NSString *nsId = [NSString stringWithCString:id encoding:[NSString defaultCStringEncoding]]; 306 | 307 | IOBluetoothDevice *device = nil; 308 | 309 | if (check_device_address_arg(id)) { 310 | device = [IOBluetoothDevice deviceWithAddressString:nsId]; 311 | 312 | if (!device) { 313 | eprintf("Device not found by address: %s\n", id); 314 | exit(EXIT_FAILURE); 315 | } 316 | } else { 317 | NSMutableArray *searchDevices = [NSMutableArray new]; 318 | 319 | NSArray *pairedDevices = [IOBluetoothDevice pairedDevices]; 320 | if (pairedDevices) { 321 | [searchDevices addObjectsFromArray:pairedDevices]; 322 | } 323 | 324 | NSArray *recentDevices = [IOBluetoothDevice recentDevices:0]; 325 | if (recentDevices) { 326 | [searchDevices addObjectsFromArray:recentDevices]; 327 | } 328 | 329 | if (searchDevices.count <= 0) { 330 | eprintf("No paired or recent devices to search for: %s\n", id); 331 | exit(EXIT_FAILURE); 332 | } 333 | 334 | NSArray *byName = [searchDevices filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"name == %@", nsId]]; 335 | if (byName.count > 0) { 336 | device = byName.firstObject; 337 | } 338 | 339 | if (!device) { 340 | eprintf("Device not found by name: %s\n", id); 341 | exit(EXIT_FAILURE); 342 | } 343 | } 344 | 345 | return device; 346 | } 347 | 348 | void list_devices_default(NSArray *devices, bool first_only) { 349 | for (IOBluetoothDevice *device in devices) { 350 | printf("address: %s", [[device addressString] UTF8String]); 351 | if ([device isConnected]) { 352 | printf(", connected (%s, %d dBm)", [device isIncoming] ? "slave" : "master", [device rawRSSI]); 353 | } else { 354 | printf(", not connected"); 355 | } 356 | printf(", %s", [device isFavorite] ? "favourite" : "not favourite"); 357 | printf(", %s", [device isPaired] ? "paired" : "not paired"); 358 | printf(", name: \"%s\"", [device name] ? [[device name] UTF8String] : "-"); 359 | printf(", recent access date: %s", 360 | [device recentAccessDate] ? [[[device recentAccessDate] description] UTF8String] : "-"); 361 | printf("\n"); 362 | if (first_only) break; 363 | } 364 | } 365 | 366 | void list_devices_new_default(NSArray *devices, bool first_only) { 367 | const char *separator = first_only ? "\n" : ", "; 368 | for (IOBluetoothDevice *device in devices) { 369 | printf("address: %s%s", [[device addressString] UTF8String], separator); 370 | printf("recent access: %s%s", 371 | [device recentAccessDate] ? [[[device recentAccessDate] description] UTF8String] : "-", 372 | separator); 373 | printf("favourite: %s%s", [device isFavorite] ? "yes" : "no", separator); 374 | printf("paired: %s%s", [device isPaired] ? "yes" : "no", separator); 375 | printf("connected: %s%s", [device isConnected] ? ([device isIncoming] ? "slave" : "master") : "no", separator); 376 | printf("rssi: %s%s", 377 | [device isConnected] ? [[NSString stringWithFormat:@"%d", [device RSSI]] UTF8String] : "-", 378 | separator); 379 | printf("raw rssi: %s%s", 380 | [device isConnected] ? [[NSString stringWithFormat:@"%d", [device rawRSSI]] UTF8String] : "-", 381 | separator); 382 | printf("name: %s\n", [device name] ? [[device name] UTF8String] : "-"); 383 | if (first_only) break; 384 | } 385 | } 386 | 387 | void list_devices_json(NSArray *devices, bool first_only, bool pretty) { 388 | NSMutableArray *descriptions = [NSMutableArray arrayWithCapacity:[devices count]]; 389 | 390 | @autoreleasepool { 391 | // https://stackoverflow.com/a/16254918/96823 392 | NSDateFormatter *dateFormatter = [[[NSDateFormatter alloc] init] autorelease]; 393 | [dateFormatter setLocale:[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]]; 394 | [dateFormatter setCalendar:[NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]]; 395 | [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"]; 396 | 397 | for (IOBluetoothDevice *device in devices) { 398 | NSMutableDictionary *description = [NSMutableDictionary dictionaryWithDictionary:@{ 399 | @"address": [device addressString], 400 | @"name": [device name] ? [device name] : [NSNull null], 401 | @"recentAccessDate": [device recentAccessDate] ? [dateFormatter stringFromDate:[device recentAccessDate]] 402 | : [NSNull null], 403 | @"favourite": [device isFavorite] ? @(YES) : @(NO), 404 | @"paired": [device isPaired] ? @(YES) : @(NO), 405 | @"connected": [device isConnected] ? @(YES) : @(NO), 406 | }]; 407 | 408 | if ([device isConnected]) { 409 | description[@"slave"] = [device isIncoming] ? @(YES) : @(NO); 410 | description[@"RSSI"] = [NSNumber numberWithChar:[device RSSI]]; 411 | description[@"rawRSSI"] = [NSNumber numberWithChar:[device rawRSSI]]; 412 | } 413 | 414 | [descriptions addObject:description]; 415 | } 416 | } 417 | 418 | NSOutputStream *stdout = [NSOutputStream outputStreamToFileAtPath:@"/dev/stdout" append:NO]; 419 | [stdout open]; 420 | id object = first_only ? [descriptions firstObject] : descriptions; 421 | NSJSONWritingOptions options = pretty ? NSJSONWritingPrettyPrinted : 0; 422 | [NSJSONSerialization writeJSONObject:object toStream:stdout options:options error:NULL]; 423 | if (pretty) { 424 | [stdout write:(const uint8_t *)"\n" maxLength:1]; 425 | } 426 | [stdout close]; 427 | } 428 | 429 | void list_devices_json_default(NSArray *devices, bool first_only) { 430 | list_devices_json(devices, first_only, false); 431 | } 432 | 433 | void list_devices_json_pretty(NSArray *devices, bool first_only) { 434 | list_devices_json(devices, first_only, true); 435 | } 436 | 437 | typedef void (*FormatterFunc)(NSArray *, bool); 438 | 439 | bool parse_output_formatter(char *arg, FormatterFunc *formatter) { 440 | if (0 == strcasecmp(arg, "default")) { 441 | *formatter = list_devices_default; 442 | return true; 443 | } 444 | 445 | if (0 == strcasecmp(arg, "new-default")) { 446 | *formatter = list_devices_new_default; 447 | return true; 448 | } 449 | 450 | if (0 == strcasecmp(arg, "json")) { 451 | *formatter = list_devices_json_default; 452 | return true; 453 | } 454 | 455 | if (0 == strcasecmp(arg, "json-pretty")) { 456 | *formatter = list_devices_json_pretty; 457 | return true; 458 | } 459 | 460 | return false; 461 | } 462 | 463 | @interface DeviceInquiryRunLoopStopper : NSObject 464 | @end 465 | @implementation DeviceInquiryRunLoopStopper 466 | - (void)deviceInquiryComplete:(__unused IOBluetoothDeviceInquiry *)sender 467 | error:(__unused IOReturn)error 468 | aborted:(__unused BOOL)aborted { 469 | CFRunLoopStop(CFRunLoopGetCurrent()); 470 | } 471 | @end 472 | 473 | static inline bool is_caseabbr(const char *name, const char *str) { 474 | size_t length = strlen(str); 475 | if (length < 1) length = 1; 476 | return strncasecmp(name, str, length) == 0; 477 | } 478 | 479 | const char *hci_error_descriptions[] = { 480 | [0x01] = "Unknown HCI Command", 481 | [0x02] = "No Connection", 482 | [0x03] = "Hardware Failure", 483 | [0x04] = "Page Timeout", 484 | [0x05] = "Authentication Failure", 485 | [0x06] = "Key Missing", 486 | [0x07] = "Memory Full", 487 | [0x08] = "Connection Timeout", 488 | [0x09] = "Max Number of Connections", 489 | [0x0a] = "Max Number of SCO Connections to a Device", 490 | [0x0b] = "ACL Connection Already Exists", 491 | [0x0c] = "Command Disallowed", 492 | [0x0d] = "Host Rejected Limited Resources", 493 | [0x0e] = "Host Rejected Security Reasons", 494 | [0x0f] = "Host Rejected Remote Device Is Personal / Host Rejected Unacceptable Device Address (2.0+)", 495 | [0x10] = "Host Timeout", 496 | [0x11] = "Unsupported Feature or Parameter Value", 497 | [0x12] = "Invalid HCI Command Parameters", 498 | [0x13] = "Other End Terminated Connection User Ended", 499 | [0x14] = "Other End Terminated Connection Low Resources", 500 | [0x15] = "Other End Terminated Connection About to Power Off", 501 | [0x16] = "Connection Terminated by Local Host", 502 | [0x17] = "Repeated Attempts", 503 | [0x18] = "Pairing Not Allowed", 504 | [0x19] = "Unknown LMP PDU", 505 | [0x1a] = "Unsupported Remote Feature", 506 | [0x1b] = "SCO Offset Rejected", 507 | [0x1c] = "SCO Interval Rejected", 508 | [0x1d] = "SCO Air Mode Rejected", 509 | [0x1e] = "Invalid LMP Parameters", 510 | [0x1f] = "Unspecified Error", 511 | [0x20] = "Unsupported LMP Parameter Value", 512 | [0x21] = "Role Change Not Allowed", 513 | [0x22] = "LMP Response Timeout", 514 | [0x23] = "LMP Error Transaction Collision", 515 | [0x24] = "LMP PDU Not Allowed", 516 | [0x25] = "Encryption Mode Not Acceptable", 517 | [0x26] = "Unit Key Used", 518 | [0x27] = "QoS Not Supported", 519 | [0x28] = "Instant Passed", 520 | [0x29] = "Pairing With Unit Key Not Supported", 521 | [0x2a] = "Different Transaction Collision", 522 | [0x2c] = "QoS Unacceptable Parameter", 523 | [0x2d] = "QoS Rejected", 524 | [0x2e] = "Channel Classification Not Supported", 525 | [0x2f] = "Insufficient Security", 526 | [0x30] = "Parameter Out of Mandatory Range", 527 | [0x31] = "Role Switch Pending", 528 | [0x34] = "Reserved Slot Violation", 529 | [0x35] = "Role Switch Failed", 530 | [0x36] = "Extended Inquiry Response Too Large", 531 | [0x37] = "Secure Simple Pairing Not Supported by Host", 532 | [0x38] = "Host Busy Pairing", 533 | [0x39] = "Connection Rejected Due to No Suitable Channel Found", 534 | [0x3a] = "Controller Busy", 535 | [0x3b] = "Unacceptable Connection Interval", 536 | [0x3c] = "Directed Advertising Timeout", 537 | [0x3d] = "Connection Terminated Due to MIC Failure", 538 | [0x3e] = "Connection Failed to Be Established", 539 | [0x3f] = "MAC Connection Failed", 540 | [0x40] = "Coarse Clock Adjustment Rejected", 541 | }; 542 | 543 | @interface DevicePairDelegate : NSObject 544 | @property (readonly) IOReturn errorCode; 545 | @property char *requestedPin; 546 | @end 547 | @implementation DevicePairDelegate 548 | - (const char *)errorDescription { 549 | if (_errorCode >= 0 && (unsigned)_errorCode < sizeof(hci_error_descriptions) / sizeof(hci_error_descriptions[0]) && 550 | hci_error_descriptions[_errorCode]) { 551 | return hci_error_descriptions[_errorCode]; 552 | } else { 553 | return "UNKNOWN ERROR"; 554 | } 555 | } 556 | 557 | - (void)devicePairingFinished:(__unused id)sender error:(IOReturn)error { 558 | _errorCode = error; 559 | CFRunLoopStop(CFRunLoopGetCurrent()); 560 | } 561 | 562 | - (void)devicePairingPINCodeRequest:(id)sender { 563 | BluetoothPINCode pinCode; 564 | ByteCount pinCodeSize; 565 | 566 | if (_requestedPin) { 567 | eprintf("Input pin %.16s on \"%s\" (%s)\n", 568 | _requestedPin, 569 | [[[sender device] name] UTF8String], 570 | [[[sender device] addressString] UTF8String]); 571 | 572 | pinCodeSize = strlen(_requestedPin); 573 | if (pinCodeSize > 16) pinCodeSize = 16; 574 | strncpy((char *)pinCode.data, _requestedPin, pinCodeSize); 575 | } else { 576 | eprintf("Type pin code (up to 16 characters) for \"%s\" (%s) and press Enter: ", 577 | [[[sender device] name] UTF8String], 578 | [[[sender device] addressString] UTF8String]); 579 | 580 | uint input_size = 16 + 2; 581 | char input[input_size]; 582 | fgets(input, input_size, stdin); 583 | input[strcspn(input, "\n")] = 0; 584 | 585 | pinCodeSize = strlen(input); 586 | strncpy((char *)pinCode.data, input, pinCodeSize); 587 | } 588 | 589 | [sender replyPINCode:pinCodeSize PINCode:&pinCode]; 590 | } 591 | 592 | - (void)devicePairingUserConfirmationRequest:(id)sender numericValue:(BluetoothNumericValue)numericValue { 593 | eprintf("Does \"%s\" (%s) display number %06u (yes/no)? ", 594 | [[[sender device] name] UTF8String], 595 | [[[sender device] addressString] UTF8String], 596 | numericValue); 597 | 598 | uint input_size = 3 + 2; 599 | char input[input_size]; 600 | fgets(input, input_size, stdin); 601 | input[strcspn(input, "\n")] = 0; 602 | 603 | if (is_caseabbr("yes", input)) { 604 | [sender replyUserConfirmation:YES]; 605 | return; 606 | } 607 | 608 | if (is_caseabbr("no", input)) { 609 | [sender replyUserConfirmation:NO]; 610 | return; 611 | } 612 | } 613 | 614 | - (void)devicePairingUserPasskeyNotification:(id)sender passkey:(BluetoothPasskey)passkey { 615 | eprintf("Input passkey %06u on \"%s\" (%s)\n", 616 | passkey, 617 | [[[sender device] name] UTF8String], 618 | [[[sender device] addressString] UTF8String]); 619 | } 620 | @end 621 | 622 | #define OP_FUNC(name, operator) \ 623 | bool op_##name(const long a, const long b) { \ 624 | return a operator b; \ 625 | } 626 | 627 | OP_FUNC(gt, >); 628 | OP_FUNC(ge, >=); 629 | OP_FUNC(lt, <); 630 | OP_FUNC(le, <=); 631 | OP_FUNC(eq, ==); 632 | OP_FUNC(ne, !=); 633 | 634 | typedef bool (*OpFunc)(const long a, const long b); 635 | 636 | #define PARSE_OP_ARG_MATCHER(name, operator) \ 637 | if (0 == strcmp(arg, #name) || 0 == strcmp(arg, #operator)) { \ 638 | *op = op_##name; \ 639 | *op_name = #operator; \ 640 | return true; \ 641 | } 642 | 643 | bool parse_op_arg(const char *arg, OpFunc *op, const char **op_name) { 644 | PARSE_OP_ARG_MATCHER(gt, >); 645 | PARSE_OP_ARG_MATCHER(ge, >=); 646 | PARSE_OP_ARG_MATCHER(lt, <); 647 | PARSE_OP_ARG_MATCHER(le, <=); 648 | PARSE_OP_ARG_MATCHER(eq, =); 649 | PARSE_OP_ARG_MATCHER(ne, !=); 650 | 651 | return false; 652 | } 653 | 654 | @interface DeviceNotificationRunLoopStopper : NSObject 655 | @end 656 | @implementation DeviceNotificationRunLoopStopper { 657 | IOBluetoothDevice *expectedDevice; 658 | } 659 | - (id)initWithExpectedDevice:(IOBluetoothDevice *)device { 660 | expectedDevice = device; 661 | return self; 662 | } 663 | - (void)notification:(IOBluetoothUserNotification *)notification fromDevice:(IOBluetoothDevice *)device { 664 | if ([expectedDevice isEqual:device]) { 665 | [notification unregister]; 666 | CFRunLoopStop(CFRunLoopGetCurrent()); 667 | } 668 | } 669 | @end 670 | 671 | struct args_state_get { 672 | GetterFunc func; 673 | }; 674 | 675 | struct args_state_set { 676 | SetterFunc func; 677 | enum state state; 678 | }; 679 | 680 | struct args_inquiry { 681 | unsigned long duration; 682 | }; 683 | 684 | struct args_recent { 685 | unsigned long max; 686 | }; 687 | 688 | struct args_device_id { 689 | char *device_id; 690 | }; 691 | 692 | struct args_pair { 693 | char *device_id; 694 | char *pin_code; 695 | }; 696 | 697 | struct args_wait_connection_change { 698 | char *device_id; 699 | bool wait_connect; 700 | unsigned long timeout; 701 | }; 702 | 703 | struct args_wait_rssi { 704 | char *device_id; 705 | OpFunc op; 706 | const char *op_name; 707 | long value; 708 | unsigned long period; 709 | unsigned long timeout; 710 | }; 711 | 712 | typedef int (^cmd)(void *args); 713 | struct cmd_with_args { 714 | cmd cmd; 715 | void *args; 716 | }; 717 | 718 | #define CMD_CHUNK 8 719 | struct cmd_with_args *cmds = NULL; 720 | size_t cmd_n = 0, cmd_reserved = 0; 721 | 722 | #define ALLOC_ARGS(type) struct args_##type *args = assert_alloc(malloc(sizeof(struct args_##type))) 723 | 724 | void add_cmd(void *args, cmd cmd) { 725 | if (cmd_n >= cmd_reserved) { 726 | cmd_reserved += CMD_CHUNK; 727 | cmds = assert_alloc(reallocf(cmds, sizeof(struct cmd_with_args) * cmd_reserved)); 728 | } 729 | cmds[cmd_n++] = (struct cmd_with_args){.cmd = cmd, .args = args}; 730 | } 731 | 732 | FormatterFunc list_devices = list_devices_default; 733 | 734 | int main(int argc, char *argv[]) { 735 | signal(SIGABRT, handle_abort); 736 | 737 | if (geteuid() == 0) { 738 | char *allow_root = getenv("BLUEUTIL_ALLOW_ROOT"); 739 | if (NULL == allow_root || 0 != strcmp(allow_root, "1")) { 740 | eprintf("Error: Not running as root user without environment variable BLUEUTIL_ALLOW_ROOT=1\n"); 741 | return EXIT_FAILURE; 742 | } 743 | } 744 | 745 | _NSSetLogCStringFunction(CustomNSLogOutput); 746 | 747 | if (!BTAvaliable()) { 748 | eprintf("Error: Bluetooth not available!\n"); 749 | return EX_UNAVAILABLE; 750 | } 751 | 752 | if (argc == 1) { 753 | printf("Power: %d\nDiscoverable: %d\n", BTPowerState(), BTDiscoverableState()); 754 | return EXIT_SUCCESS; 755 | } 756 | 757 | enum { 758 | arg_power = 'p', 759 | arg_discoverable = 'd', 760 | arg_help = 'h', 761 | arg_version = 'v', 762 | 763 | arg_favourites = 256, 764 | arg_inquiry, 765 | arg_paired, 766 | arg_recent, 767 | arg_connected, 768 | 769 | arg_info, 770 | arg_is_connected, 771 | arg_connect, 772 | arg_disconnect, 773 | arg_pair, 774 | arg_unpair, 775 | arg_add_favourite, 776 | arg_remove_favourite, 777 | 778 | arg_format, 779 | 780 | arg_wait_connect, 781 | arg_wait_disconnect, 782 | arg_wait_rssi, 783 | }; 784 | 785 | const char *optstring = "p::d::hv"; 786 | // clang-format off 787 | static struct option long_options[] = { 788 | {"power", optional_argument, NULL, arg_power}, 789 | {"discoverable", optional_argument, NULL, arg_discoverable}, 790 | 791 | {"favourites", no_argument, NULL, arg_favourites}, 792 | {"favorites", no_argument, NULL, arg_favourites}, 793 | {"inquiry", optional_argument, NULL, arg_inquiry}, 794 | {"paired", no_argument, NULL, arg_paired}, 795 | {"recent", optional_argument, NULL, arg_recent}, 796 | {"connected", no_argument, NULL, arg_connected}, 797 | 798 | {"info", required_argument, NULL, arg_info}, 799 | {"is-connected", required_argument, NULL, arg_is_connected}, 800 | {"connect", required_argument, NULL, arg_connect}, 801 | {"disconnect", required_argument, NULL, arg_disconnect}, 802 | {"pair", required_argument, NULL, arg_pair}, 803 | {"unpair", required_argument, NULL, arg_unpair}, 804 | {"add-favourite", required_argument, NULL, arg_add_favourite}, 805 | {"add-favorite", required_argument, NULL, arg_add_favourite}, 806 | {"remove-favourite", required_argument, NULL, arg_remove_favourite}, 807 | {"remove-favorite", required_argument, NULL, arg_remove_favourite}, 808 | 809 | {"format", required_argument, NULL, arg_format}, 810 | 811 | {"wait-connect", required_argument, NULL, arg_wait_connect}, 812 | {"wait-disconnect", required_argument, NULL, arg_wait_disconnect}, 813 | {"wait-rssi", required_argument, NULL, arg_wait_rssi}, 814 | 815 | {"help", no_argument, NULL, arg_help}, 816 | {"version", no_argument, NULL, arg_version}, 817 | 818 | {NULL, 0, NULL, 0} 819 | }; 820 | // clang-format on 821 | 822 | int arg; 823 | while ((arg = getopt_long(argc, argv, optstring, long_options, NULL)) != -1) { 824 | switch (arg) { 825 | case arg_power: 826 | case arg_discoverable: { 827 | extend_optarg(argc, argv); 828 | 829 | if (optarg) { 830 | ALLOC_ARGS(state_set); 831 | 832 | args->func = arg == arg_power ? BTSetPowerState : BTSetDiscoverableState; 833 | 834 | if (!parse_state_arg(optarg, &args->state)) { 835 | eprintf("Unexpected value: %s\n", optarg); 836 | return EX_USAGE; 837 | } 838 | 839 | add_cmd(args, ^int(void *_args) { 840 | struct args_state_set *args = (struct args_state_set *)_args; 841 | 842 | return args->func(args->state) ? EXIT_SUCCESS : EX_TEMPFAIL; 843 | }); 844 | } else { 845 | ALLOC_ARGS(state_get); 846 | 847 | args->func = arg == arg_power ? BTPowerState : BTDiscoverableState; 848 | 849 | add_cmd(args, ^int(void *_args) { 850 | struct args_state_get *args = (struct args_state_get *)_args; 851 | 852 | printf("%d\n", args->func()); 853 | 854 | return EXIT_SUCCESS; 855 | }); 856 | } 857 | } break; 858 | case arg_favourites: { 859 | add_cmd(NULL, ^int(__unused void *_args) { 860 | list_devices([IOBluetoothDevice favoriteDevices], false); 861 | return EXIT_SUCCESS; 862 | }); 863 | } break; 864 | case arg_paired: { 865 | add_cmd(NULL, ^int(__unused void *_args) { 866 | list_devices([IOBluetoothDevice pairedDevices], false); 867 | return EXIT_SUCCESS; 868 | }); 869 | } break; 870 | case arg_inquiry: { 871 | ALLOC_ARGS(inquiry); 872 | 873 | extend_optarg(argc, argv); 874 | args->duration = 10; 875 | if (optarg) { 876 | if (!parse_unsigned_long_arg(optarg, &args->duration)) { 877 | eprintf("Expected numeric duration, got: %s\n", optarg); 878 | return EX_USAGE; 879 | } 880 | } 881 | 882 | add_cmd(args, ^int(void *_args) { 883 | struct args_inquiry *args = (struct args_inquiry *)_args; 884 | 885 | check_power_on_for("inquiry"); 886 | 887 | @autoreleasepool { 888 | DeviceInquiryRunLoopStopper *stopper = [[[DeviceInquiryRunLoopStopper alloc] init] autorelease]; 889 | IOBluetoothDeviceInquiry *inquirer = [IOBluetoothDeviceInquiry inquiryWithDelegate:stopper]; 890 | 891 | [inquirer start]; 892 | 893 | // inquiry length seems to be ingored starting with Monterey 894 | dispatch_after(dispatch_time(DISPATCH_TIME_NOW, args->duration * NSEC_PER_SEC), 895 | dispatch_get_main_queue(), 896 | ^{ 897 | [inquirer stop]; 898 | }); 899 | 900 | CFRunLoopRun(); 901 | 902 | list_devices([inquirer foundDevices], false); 903 | } 904 | 905 | return EXIT_SUCCESS; 906 | }); 907 | } break; 908 | case arg_recent: { 909 | ALLOC_ARGS(recent); 910 | 911 | extend_optarg(argc, argv); 912 | args->max = 10; 913 | if (optarg) { 914 | if (!parse_unsigned_long_arg(optarg, &args->max)) { 915 | eprintf("Expected numeric count, got: %s\n", optarg); 916 | return EX_USAGE; 917 | } 918 | } 919 | 920 | add_cmd(args, ^int(void *_args) { 921 | struct args_recent *args = (struct args_recent *)_args; 922 | 923 | list_devices([IOBluetoothDevice recentDevices:args->max], false); 924 | 925 | return EXIT_SUCCESS; 926 | }); 927 | } break; 928 | case arg_connected: { 929 | add_cmd(NULL, ^int(__unused void *_args) { 930 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isConnected == YES"]; 931 | list_devices([[IOBluetoothDevice pairedDevices] filteredArrayUsingPredicate:predicate], false); 932 | return EXIT_SUCCESS; 933 | }); 934 | } break; 935 | case arg_info: { 936 | ALLOC_ARGS(device_id); 937 | 938 | args->device_id = optarg; 939 | 940 | add_cmd(args, ^int(void *_args) { 941 | struct args_device_id *args = (struct args_device_id *)_args; 942 | 943 | list_devices(@[get_device(args->device_id)], true); 944 | 945 | return EXIT_SUCCESS; 946 | }); 947 | } break; 948 | case arg_is_connected: { 949 | ALLOC_ARGS(device_id); 950 | 951 | args->device_id = optarg; 952 | 953 | add_cmd(args, ^int(void *_args) { 954 | struct args_device_id *args = (struct args_device_id *)_args; 955 | 956 | printf("%d\n", [get_device(args->device_id) isConnected] ? 1 : 0); 957 | 958 | return EXIT_SUCCESS; 959 | }); 960 | } break; 961 | case arg_connect: { 962 | ALLOC_ARGS(device_id); 963 | 964 | args->device_id = optarg; 965 | 966 | add_cmd(args, ^int(void *_args) { 967 | struct args_device_id *args = (struct args_device_id *)_args; 968 | 969 | check_power_on_for("connect"); 970 | 971 | if ([get_device(args->device_id) openConnection] != kIOReturnSuccess) { 972 | eprintf("Failed to connect \"%s\"\n", args->device_id); 973 | return EXIT_FAILURE; 974 | } 975 | 976 | return EXIT_SUCCESS; 977 | }); 978 | } break; 979 | case arg_disconnect: { 980 | ALLOC_ARGS(device_id); 981 | 982 | args->device_id = optarg; 983 | 984 | add_cmd(args, ^int(void *_args) { 985 | struct args_device_id *args = (struct args_device_id *)_args; 986 | 987 | check_power_on_for("disconnect"); 988 | 989 | @autoreleasepool { 990 | IOBluetoothDevice *device = get_device(args->device_id); 991 | 992 | DeviceNotificationRunLoopStopper *stopper = 993 | [[[DeviceNotificationRunLoopStopper alloc] initWithExpectedDevice:device] autorelease]; 994 | 995 | [device registerForDisconnectNotification:stopper selector:@selector(notification:fromDevice:)]; 996 | 997 | if ([device closeConnection] != kIOReturnSuccess) { 998 | eprintf("Failed to disconnect \"%s\"\n", args->device_id); 999 | return EXIT_FAILURE; 1000 | } 1001 | 1002 | CFRunLoopRun(); 1003 | } 1004 | 1005 | return EXIT_SUCCESS; 1006 | }); 1007 | } break; 1008 | case arg_add_favourite: { 1009 | ALLOC_ARGS(device_id); 1010 | 1011 | args->device_id = optarg; 1012 | 1013 | add_cmd(args, ^int(void *_args) { 1014 | struct args_device_id *args = (struct args_device_id *)_args; 1015 | 1016 | if ([get_device(args->device_id) addToFavorites] != kIOReturnSuccess) { 1017 | eprintf("Failed to add \"%s\" to favourites\n", args->device_id); 1018 | return EXIT_FAILURE; 1019 | } 1020 | 1021 | return EXIT_SUCCESS; 1022 | }); 1023 | } break; 1024 | case arg_remove_favourite: { 1025 | ALLOC_ARGS(device_id); 1026 | 1027 | args->device_id = optarg; 1028 | 1029 | add_cmd(args, ^int(void *_args) { 1030 | struct args_device_id *args = (struct args_device_id *)_args; 1031 | 1032 | if ([get_device(args->device_id) removeFromFavorites] != kIOReturnSuccess) { 1033 | eprintf("Failed to remove \"%s\" from favourites\n", args->device_id); 1034 | return EXIT_FAILURE; 1035 | } 1036 | 1037 | return EXIT_SUCCESS; 1038 | }); 1039 | } break; 1040 | case arg_pair: { 1041 | ALLOC_ARGS(pair); 1042 | 1043 | args->device_id = optarg; 1044 | args->pin_code = next_optarg(argc, argv); 1045 | 1046 | if (args->pin_code && strlen(args->pin_code) > 16) { 1047 | eprintf("Pairing pin can't be longer than 16 characters, got %lu (%s)\n", 1048 | strlen(args->pin_code), 1049 | args->pin_code); 1050 | return EX_USAGE; 1051 | } 1052 | 1053 | add_cmd(args, ^int(void *_args) { 1054 | struct args_pair *args = (struct args_pair *)_args; 1055 | 1056 | check_power_on_for("pair"); 1057 | 1058 | @autoreleasepool { 1059 | IOBluetoothDevice *device = get_device(args->device_id); 1060 | DevicePairDelegate *delegate = [[[DevicePairDelegate alloc] init] autorelease]; 1061 | IOBluetoothDevicePair *pairer = [IOBluetoothDevicePair pairWithDevice:device]; 1062 | pairer.delegate = delegate; 1063 | 1064 | delegate.requestedPin = args->pin_code; 1065 | 1066 | if ([pairer start] != kIOReturnSuccess) { 1067 | eprintf("Failed to start pairing with \"%s\"\n", args->device_id); 1068 | return EXIT_FAILURE; 1069 | } 1070 | CFRunLoopRun(); 1071 | [pairer stop]; 1072 | 1073 | if (![device isPaired]) { 1074 | eprintf("Failed to pair \"%s\" with error 0x%02x (%s)\n", 1075 | args->device_id, 1076 | [delegate errorCode], 1077 | [delegate errorDescription]); 1078 | return EXIT_FAILURE; 1079 | } 1080 | } 1081 | 1082 | return EXIT_SUCCESS; 1083 | }); 1084 | } break; 1085 | case arg_unpair: { 1086 | ALLOC_ARGS(device_id); 1087 | 1088 | args->device_id = optarg; 1089 | 1090 | add_cmd(args, ^int(void *_args) { 1091 | struct args_device_id *args = (struct args_device_id *)_args; 1092 | 1093 | IOBluetoothDevice *device = get_device(args->device_id); 1094 | 1095 | #pragma clang diagnostic push 1096 | #pragma clang diagnostic ignored "-Wundeclared-selector" 1097 | if ([device respondsToSelector:@selector(remove)]) { 1098 | [device performSelector:@selector(remove)]; 1099 | #pragma clang diagnostic pop 1100 | return EXIT_SUCCESS; 1101 | } else { 1102 | return EX_UNAVAILABLE; 1103 | } 1104 | }); 1105 | } break; 1106 | case arg_format: { 1107 | if (!parse_output_formatter(optarg, &list_devices)) { 1108 | eprintf("Unexpected format: %s\n", optarg); 1109 | return EX_USAGE; 1110 | } 1111 | } break; 1112 | case arg_wait_connect: 1113 | case arg_wait_disconnect: { 1114 | ALLOC_ARGS(wait_connection_change); 1115 | 1116 | args->wait_connect = arg == arg_wait_connect; 1117 | args->device_id = optarg; 1118 | 1119 | char *timeout_arg = next_optarg(argc, argv); 1120 | args->timeout = 0; 1121 | if (timeout_arg && !parse_unsigned_long_arg(timeout_arg, &args->timeout)) { 1122 | eprintf("Expected numeric timeout, got: %s\n", timeout_arg); 1123 | return EX_USAGE; 1124 | } 1125 | 1126 | add_cmd(args, ^int(void *_args) { 1127 | struct args_wait_connection_change *args = (struct args_wait_connection_change *)_args; 1128 | 1129 | @autoreleasepool { 1130 | IOBluetoothDevice *device = get_device(args->device_id); 1131 | 1132 | DeviceNotificationRunLoopStopper *stopper = 1133 | [[[DeviceNotificationRunLoopStopper alloc] initWithExpectedDevice:device] autorelease]; 1134 | 1135 | CFRunLoopTimerRef timer = 1136 | CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 0, 0, 0, ^(__unused CFRunLoopTimerRef timer) { 1137 | if (args->wait_connect) { 1138 | if ([device isConnected]) { 1139 | CFRunLoopStop(CFRunLoopGetCurrent()); 1140 | } else { 1141 | [IOBluetoothDevice registerForConnectNotifications:stopper 1142 | selector:@selector(notification:fromDevice:)]; 1143 | } 1144 | } else { 1145 | if ([device isConnected]) { 1146 | [device registerForDisconnectNotification:stopper selector:@selector(notification:fromDevice:)]; 1147 | } else { 1148 | CFRunLoopStop(CFRunLoopGetCurrent()); 1149 | } 1150 | } 1151 | }); 1152 | CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopDefaultMode); 1153 | 1154 | if (args->timeout > 0) { 1155 | if (kCFRunLoopRunTimedOut == CFRunLoopRunInMode(kCFRunLoopDefaultMode, args->timeout, false)) { 1156 | eprintf("Timed out waiting for \"%s\" to %s\n", optarg, args->wait_connect ? "connect" : "disconnect"); 1157 | return EX_TEMPFAIL; 1158 | } 1159 | } else { 1160 | CFRunLoopRun(); 1161 | } 1162 | 1163 | CFRunLoopRemoveTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopDefaultMode); 1164 | CFRelease(timer); 1165 | } 1166 | 1167 | return EXIT_SUCCESS; 1168 | }); 1169 | } break; 1170 | case arg_wait_rssi: { 1171 | ALLOC_ARGS(wait_rssi); 1172 | 1173 | args->device_id = optarg; 1174 | 1175 | char *op_arg = next_reqarg(argc, argv); 1176 | if (!op_arg) { 1177 | eprintf("%s: option `%s' requires 2nd argument\n", argv[0], argv[optind - 2]); 1178 | usage(stderr); 1179 | return EX_USAGE; 1180 | } else if (!parse_op_arg(op_arg, &args->op, &args->op_name)) { 1181 | eprintf("Expected operator, got: %s\n", op_arg); 1182 | return EX_USAGE; 1183 | } 1184 | 1185 | char *value_arg = next_reqarg(argc, argv); 1186 | if (!value_arg) { 1187 | eprintf("%s: option `%s' requires 3rd argument\n", argv[0], argv[optind - 3]); 1188 | usage(stderr); 1189 | return EX_USAGE; 1190 | } else if (!parse_signed_long_arg(value_arg, &args->value)) { 1191 | eprintf("Expected numeric value, got: %s\n", value_arg); 1192 | return EX_USAGE; 1193 | } 1194 | 1195 | char *period_arg = next_optarg(argc, argv); 1196 | args->period = 1; 1197 | if (period_arg) { 1198 | if (!parse_unsigned_long_arg(period_arg, &args->period)) { 1199 | eprintf("Expected numeric period, got: %s\n", period_arg); 1200 | return EX_USAGE; 1201 | } else if (args->period < 1) { 1202 | eprintf("Expected period to be at least 1, got: %ld\n", args->period); 1203 | return EX_USAGE; 1204 | } 1205 | } 1206 | 1207 | char *timeout_arg = next_optarg(argc, argv); 1208 | args->timeout = 0; 1209 | if (timeout_arg && !parse_unsigned_long_arg(timeout_arg, &args->timeout)) { 1210 | eprintf("Expected numeric timeout, got: %s\n", timeout_arg); 1211 | return EX_USAGE; 1212 | } 1213 | 1214 | add_cmd(args, ^int(void *_args) { 1215 | struct args_wait_rssi *args = (struct args_wait_rssi *)_args; 1216 | 1217 | IOBluetoothDevice *device = get_device(args->device_id); 1218 | 1219 | CFRunLoopTimerRef timer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 1220 | 0, 1221 | args->period, 1222 | 0, 1223 | 0, 1224 | ^(__unused CFRunLoopTimerRef timer) { 1225 | long rssi = [device RSSI]; 1226 | if (rssi == 127) rssi = -129; 1227 | if (args->op(rssi, args->value)) { 1228 | CFRunLoopStop(CFRunLoopGetCurrent()); 1229 | } 1230 | }); 1231 | CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopDefaultMode); 1232 | 1233 | if (args->timeout > 0) { 1234 | if (kCFRunLoopRunTimedOut == CFRunLoopRunInMode(kCFRunLoopDefaultMode, args->timeout, false)) { 1235 | eprintf("Timed out waiting for rssi of \"%s\" to be %s %ld\n", 1236 | args->device_id, 1237 | args->op_name, 1238 | args->value); 1239 | return EX_TEMPFAIL; 1240 | } 1241 | } else { 1242 | CFRunLoopRun(); 1243 | } 1244 | 1245 | CFRunLoopRemoveTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopDefaultMode); 1246 | CFRelease(timer); 1247 | 1248 | return EXIT_SUCCESS; 1249 | }); 1250 | } break; 1251 | case arg_version: { 1252 | printf(VERSION "\n"); 1253 | return EXIT_SUCCESS; 1254 | } 1255 | case arg_help: { 1256 | usage(stdout); 1257 | return EXIT_SUCCESS; 1258 | } 1259 | default: { 1260 | usage(stderr); 1261 | return EX_USAGE; 1262 | } 1263 | } 1264 | } 1265 | 1266 | if (optind < argc) { 1267 | eprintf("Unexpected arguments: %s", argv[optind++]); 1268 | while (optind < argc) { 1269 | eprintf(", %s", argv[optind++]); 1270 | } 1271 | eprintf("\n"); 1272 | return EX_USAGE; 1273 | } 1274 | 1275 | for (size_t i = 0; i < cmd_n; i++) { 1276 | int status = cmds[i].cmd(cmds[i].args); 1277 | if (status != EXIT_SUCCESS) return status; 1278 | free(cmds[i].args); 1279 | } 1280 | free(cmds); 1281 | 1282 | return EXIT_SUCCESS; 1283 | } 1284 | -------------------------------------------------------------------------------- /blueutil.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8DD76F9A0486AA7600D96B5E /* blueutil.m in Sources */ = {isa = PBXBuildFile; fileRef = 08FB7796FE84155DC02AAC07 /* blueutil.m */; settings = {ATTRIBUTES = (); }; }; 11 | B2A6DE1312F4624400C5007F /* IOBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2A6DE1212F4624400C5007F /* IOBluetooth.framework */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXCopyFilesBuildPhase section */ 15 | 8DD76F9E0486AA7600D96B5E /* CopyFiles */ = { 16 | isa = PBXCopyFilesBuildPhase; 17 | buildActionMask = 8; 18 | dstPath = /usr/share/man/man1/; 19 | dstSubfolderSpec = 0; 20 | files = ( 21 | ); 22 | runOnlyForDeploymentPostprocessing = 1; 23 | }; 24 | /* End PBXCopyFilesBuildPhase section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 08FB7796FE84155DC02AAC07 /* blueutil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = blueutil.m; sourceTree = ""; }; 28 | 32A70AAB03705E1F00C91783 /* blueutil_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = blueutil_Prefix.pch; sourceTree = ""; }; 29 | 8DD76FA10486AA7600D96B5E /* blueutil */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = blueutil; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | B2A6DE1212F4624400C5007F /* IOBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOBluetooth.framework; path = System/Library/Frameworks/IOBluetooth.framework; sourceTree = SDKROOT; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFrameworksBuildPhase section */ 34 | 8DD76F9B0486AA7600D96B5E /* Frameworks */ = { 35 | isa = PBXFrameworksBuildPhase; 36 | buildActionMask = 2147483647; 37 | files = ( 38 | B2A6DE1312F4624400C5007F /* IOBluetooth.framework in Frameworks */, 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | 08FB7794FE84155DC02AAC07 /* blueutil */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | 08FB7795FE84155DC02AAC07 /* Source */, 49 | 08FB779DFE84155DC02AAC07 /* External Frameworks and Libraries */, 50 | 1AB674ADFE9D54B511CA2CBB /* Products */, 51 | ); 52 | name = blueutil; 53 | sourceTree = ""; 54 | }; 55 | 08FB7795FE84155DC02AAC07 /* Source */ = { 56 | isa = PBXGroup; 57 | children = ( 58 | 32A70AAB03705E1F00C91783 /* blueutil_Prefix.pch */, 59 | 08FB7796FE84155DC02AAC07 /* blueutil.m */, 60 | ); 61 | name = Source; 62 | sourceTree = ""; 63 | }; 64 | 08FB779DFE84155DC02AAC07 /* External Frameworks and Libraries */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | B2A6DE1212F4624400C5007F /* IOBluetooth.framework */, 68 | ); 69 | name = "External Frameworks and Libraries"; 70 | sourceTree = ""; 71 | }; 72 | 1AB674ADFE9D54B511CA2CBB /* Products */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | 8DD76FA10486AA7600D96B5E /* blueutil */, 76 | ); 77 | name = Products; 78 | sourceTree = ""; 79 | }; 80 | /* End PBXGroup section */ 81 | 82 | /* Begin PBXNativeTarget section */ 83 | 8DD76F960486AA7600D96B5E /* blueutil */ = { 84 | isa = PBXNativeTarget; 85 | buildConfigurationList = 1DEB927408733DD40010E9CD /* Build configuration list for PBXNativeTarget "blueutil" */; 86 | buildPhases = ( 87 | 8DD76F990486AA7600D96B5E /* Sources */, 88 | 8DD76F9B0486AA7600D96B5E /* Frameworks */, 89 | 8DD76F9E0486AA7600D96B5E /* CopyFiles */, 90 | ); 91 | buildRules = ( 92 | ); 93 | dependencies = ( 94 | ); 95 | name = blueutil; 96 | productInstallPath = "$(HOME)/bin"; 97 | productName = blueutil; 98 | productReference = 8DD76FA10486AA7600D96B5E /* blueutil */; 99 | productType = "com.apple.product-type.tool"; 100 | }; 101 | /* End PBXNativeTarget section */ 102 | 103 | /* Begin PBXProject section */ 104 | 08FB7793FE84155DC02AAC07 /* Project object */ = { 105 | isa = PBXProject; 106 | attributes = { 107 | LastUpgradeCheck = 0830; 108 | }; 109 | buildConfigurationList = 1DEB927808733DD40010E9CD /* Build configuration list for PBXProject "blueutil" */; 110 | compatibilityVersion = "Xcode 3.2"; 111 | developmentRegion = English; 112 | hasScannedForEncodings = 1; 113 | knownRegions = ( 114 | en, 115 | ); 116 | mainGroup = 08FB7794FE84155DC02AAC07 /* blueutil */; 117 | projectDirPath = ""; 118 | projectRoot = ""; 119 | targets = ( 120 | 8DD76F960486AA7600D96B5E /* blueutil */, 121 | ); 122 | }; 123 | /* End PBXProject section */ 124 | 125 | /* Begin PBXSourcesBuildPhase section */ 126 | 8DD76F990486AA7600D96B5E /* Sources */ = { 127 | isa = PBXSourcesBuildPhase; 128 | buildActionMask = 2147483647; 129 | files = ( 130 | 8DD76F9A0486AA7600D96B5E /* blueutil.m in Sources */, 131 | ); 132 | runOnlyForDeploymentPostprocessing = 0; 133 | }; 134 | /* End PBXSourcesBuildPhase section */ 135 | 136 | /* Begin XCBuildConfiguration section */ 137 | 1DEB927508733DD40010E9CD /* Debug */ = { 138 | isa = XCBuildConfiguration; 139 | buildSettings = { 140 | ALWAYS_SEARCH_USER_PATHS = NO; 141 | COPY_PHASE_STRIP = NO; 142 | GCC_DYNAMIC_NO_PIC = NO; 143 | GCC_ENABLE_FIX_AND_CONTINUE = YES; 144 | GCC_MODEL_TUNING = G5; 145 | GCC_OPTIMIZATION_LEVEL = 0; 146 | GCC_PRECOMPILE_PREFIX_HEADER = YES; 147 | GCC_PREFIX_HEADER = blueutil_Prefix.pch; 148 | INSTALL_PATH = /usr/local/bin; 149 | PRODUCT_NAME = blueutil; 150 | }; 151 | name = Debug; 152 | }; 153 | 1DEB927608733DD40010E9CD /* Release */ = { 154 | isa = XCBuildConfiguration; 155 | buildSettings = { 156 | ALWAYS_SEARCH_USER_PATHS = NO; 157 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 158 | GCC_MODEL_TUNING = G5; 159 | GCC_PRECOMPILE_PREFIX_HEADER = YES; 160 | GCC_PREFIX_HEADER = blueutil_Prefix.pch; 161 | INSTALL_PATH = /usr/local/bin; 162 | PRODUCT_NAME = blueutil; 163 | }; 164 | name = Release; 165 | }; 166 | 1DEB927908733DD40010E9CD /* Debug */ = { 167 | isa = XCBuildConfiguration; 168 | buildSettings = { 169 | CLANG_WARN_BOOL_CONVERSION = YES; 170 | CLANG_WARN_CONSTANT_CONVERSION = YES; 171 | CLANG_WARN_EMPTY_BODY = YES; 172 | CLANG_WARN_ENUM_CONVERSION = YES; 173 | CLANG_WARN_INFINITE_RECURSION = YES; 174 | CLANG_WARN_INT_CONVERSION = YES; 175 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 176 | CLANG_WARN_UNREACHABLE_CODE = YES; 177 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 178 | ENABLE_STRICT_OBJC_MSGSEND = YES; 179 | ENABLE_TESTABILITY = YES; 180 | GCC_C_LANGUAGE_STANDARD = gnu99; 181 | GCC_NO_COMMON_BLOCKS = YES; 182 | GCC_OPTIMIZATION_LEVEL = 0; 183 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 185 | GCC_WARN_UNDECLARED_SELECTOR = YES; 186 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 187 | GCC_WARN_UNUSED_FUNCTION = YES; 188 | GCC_WARN_UNUSED_VARIABLE = YES; 189 | MACOSX_DEPLOYMENT_TARGET = 10.9; 190 | ONLY_ACTIVE_ARCH = YES; 191 | PREBINDING = NO; 192 | }; 193 | name = Debug; 194 | }; 195 | 1DEB927A08733DD40010E9CD /* Release */ = { 196 | isa = XCBuildConfiguration; 197 | buildSettings = { 198 | CLANG_WARN_BOOL_CONVERSION = YES; 199 | CLANG_WARN_CONSTANT_CONVERSION = YES; 200 | CLANG_WARN_EMPTY_BODY = YES; 201 | CLANG_WARN_ENUM_CONVERSION = YES; 202 | CLANG_WARN_INFINITE_RECURSION = YES; 203 | CLANG_WARN_INT_CONVERSION = YES; 204 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 205 | CLANG_WARN_UNREACHABLE_CODE = YES; 206 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 207 | ENABLE_STRICT_OBJC_MSGSEND = YES; 208 | GCC_C_LANGUAGE_STANDARD = gnu99; 209 | GCC_NO_COMMON_BLOCKS = YES; 210 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 211 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 212 | GCC_WARN_UNDECLARED_SELECTOR = YES; 213 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 214 | GCC_WARN_UNUSED_FUNCTION = YES; 215 | GCC_WARN_UNUSED_VARIABLE = YES; 216 | MACOSX_DEPLOYMENT_TARGET = 10.9; 217 | PREBINDING = NO; 218 | }; 219 | name = Release; 220 | }; 221 | /* End XCBuildConfiguration section */ 222 | 223 | /* Begin XCConfigurationList section */ 224 | 1DEB927408733DD40010E9CD /* Build configuration list for PBXNativeTarget "blueutil" */ = { 225 | isa = XCConfigurationList; 226 | buildConfigurations = ( 227 | 1DEB927508733DD40010E9CD /* Debug */, 228 | 1DEB927608733DD40010E9CD /* Release */, 229 | ); 230 | defaultConfigurationIsVisible = 0; 231 | defaultConfigurationName = Release; 232 | }; 233 | 1DEB927808733DD40010E9CD /* Build configuration list for PBXProject "blueutil" */ = { 234 | isa = XCConfigurationList; 235 | buildConfigurations = ( 236 | 1DEB927908733DD40010E9CD /* Debug */, 237 | 1DEB927A08733DD40010E9CD /* Release */, 238 | ); 239 | defaultConfigurationIsVisible = 0; 240 | defaultConfigurationName = Release; 241 | }; 242 | /* End XCConfigurationList section */ 243 | }; 244 | rootObject = 08FB7793FE84155DC02AAC07 /* Project object */; 245 | } 246 | -------------------------------------------------------------------------------- /blueutil_Prefix.pch: -------------------------------------------------------------------------------- 1 | // 2 | // Prefix header for all source files of the 'blueutil' target in the 'blueutil' project. 3 | // 4 | 5 | #ifdef __OBJC__ 6 | #import 7 | #endif 8 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | require 'date' 5 | 6 | MAIN_FILE = 'blueutil.m' 7 | DEFINE_VERSION_REGEXP = /(?<=#define VERSION ")\d+(?:\.\d+)+(?=")/ 8 | 9 | version_parts = File.read(MAIN_FILE)[DEFINE_VERSION_REGEXP].split('.').map(&:to_i) 10 | 11 | new_version = case ARGV 12 | when %w[major] 13 | "#{version_parts[0] + 1}.0.0" 14 | when %w[minor] 15 | "#{version_parts[0]}.#{version_parts[1] + 1}.0" 16 | when %w[patch] 17 | "#{version_parts[0]}.#{version_parts[1]}.#{version_parts[2] + 1}" 18 | else 19 | abort 'Expected major, minor or patch as the only argument' 20 | end 21 | 22 | def clean_workind_directory? 23 | `git status --porcelain`.empty? 24 | end 25 | 26 | clean_workind_directory? or abort('Working directory not clean') 27 | 28 | system './update_usage' 29 | 30 | clean_workind_directory? or abort('Usage in README is not up to date') 31 | 32 | paths = Pathname.glob('*').select do |path| 33 | next unless path.file? 34 | next if path.executable? 35 | 36 | original = path.read 37 | changed = original.gsub(/(?Copyright \(c\) )(?\d+)(?:-\d+)?(? \w+ \w+)/) do 38 | m = Regexp.last_match 39 | "#{m[:before]}#{[m[:year].to_i, Time.now.year].uniq.join('-')}#{m[:after]}" 40 | end 41 | 42 | case path.to_s 43 | when MAIN_FILE 44 | changed = changed.sub(DEFINE_VERSION_REGEXP, new_version) 45 | when 'CHANGELOG.md' 46 | lines = changed.lines 47 | { 48 | 2 => "## unreleased\n", 49 | 3 => "\n", 50 | }.each do |n, expected| 51 | abort "Expected #{expected} on line #{n}, got #{lines[n]}" unless lines[n] == expected 52 | end 53 | lines.insert(3, "\n", "## v#{new_version} (#{Date.today.strftime('%Y-%m-%d')})\n") 54 | changed = lines.join 55 | end 56 | 57 | next if original == changed 58 | 59 | path.open('w'){ |f| f.write(changed) } 60 | end 61 | 62 | Pathname('blueutil').unlink 63 | system(*%w[make blueutil]) or abort('failed to build') 64 | `./blueutil -h`[new_version] or abort('did not find new version in help output') 65 | system *%w[git add] + paths.map(&:to_s) 66 | system *%w[git diff --cached] 67 | 68 | puts %q{Type "yes" to continue} 69 | abort unless $stdin.gets.strip == 'yes' 70 | 71 | system *%W[git commit -m v#{new_version}] 72 | system *%W[git tag v#{new_version}] 73 | system *%w[git push] 74 | system *%w[git push --tags] 75 | -------------------------------------------------------------------------------- /test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! read -t 0.001; then 4 | cat <<-EOF 5 | WARNING! This test script will turn the bluetooth power and discoverability on 6 | and off several times. While it will try to restore the original state at the 7 | end of the test, it is recommended to have wired keyboard and pointing device at 8 | hand. You can skip the confirmation by piping yes to the script. 9 | EOF 10 | read -p "Please type y or Y followed by [ENTER] to proceed: " 11 | fi 12 | [[ $REPLY =~ ^[Yy]$ ]] || exit 1 13 | 14 | blueutil=./blueutil 15 | errors= 16 | 17 | status=$($blueutil) 18 | trap "{ 19 | $blueutil -p$($blueutil -p) -d$($blueutil -d) 20 | echo 21 | if [[ -n \$errors ]]; then 22 | echo -n \"\$errors\" 23 | exit 1 24 | fi 25 | }" EXIT 26 | 27 | ANSI_BOLD=$(tput bold 2> /dev/null) 28 | ANSI_RED=$(tput setaf 1 2> /dev/null) 29 | ANSI_GREEN=$(tput setaf 2 2> /dev/null) 30 | ANSI_RESET=$(tput sgr0 2> /dev/null) 31 | 32 | success() { 33 | printf "$ANSI_GREEN"."$ANSI_RESET" 34 | } 35 | 36 | failure() { 37 | printf "$ANSI_RED"F"$ANSI_RESET" 38 | errors+="$@"$'\n' 39 | } 40 | 41 | assert_eq() { 42 | [[ $1 == $2 ]] && success || failure "$ANSI_BOLD""$BASH_LINENO""$ANSI_RESET: Expected \"$1\" to eq \"$2\"" 43 | } 44 | 45 | assert_match() { 46 | [[ $1 =~ $2 ]] && success || failure "$ANSI_BOLD""$BASH_LINENO""$ANSI_RESET: Expected \"$1\" to match \"$2\"" 47 | } 48 | 49 | # Power 50 | $blueutil -p1 51 | assert_eq "$($blueutil)" *'Power: 1'* 52 | 53 | $blueutil -p 0 54 | assert_eq "$($blueutil)" *'Power: 0'* 55 | 56 | $blueutil --power=1 57 | assert_eq "$($blueutil)" *'Power: 1'* 58 | assert_eq "$($blueutil -p)" '1' 59 | assert_eq "$($blueutil --pow)" '1' 60 | assert_eq "$($blueutil --power)" '1' 61 | 62 | $blueutil --pow 0 63 | assert_eq "$($blueutil)" *'Power: 0'* 64 | assert_eq "$($blueutil -p)" '0' 65 | assert_eq "$($blueutil --pow)" '0' 66 | assert_eq "$($blueutil --power)" '0' 67 | 68 | # Discoverable 69 | $blueutil -d1 70 | assert_eq "$($blueutil)" *'Discoverable: 1'* 71 | 72 | $blueutil -d 0 73 | assert_eq "$($blueutil)" *'Discoverable: 0'* 74 | 75 | $blueutil --discoverable=1 76 | assert_eq "$($blueutil)" *'Discoverable: 1'* 77 | assert_eq "$($blueutil -d)" '1' 78 | assert_eq "$($blueutil --discov)" '1' 79 | assert_eq "$($blueutil --discoverable)" '1' 80 | 81 | $blueutil --discov 0 82 | assert_eq "$($blueutil)" *'Discoverable: 0'* 83 | assert_eq "$($blueutil -d)" '0' 84 | assert_eq "$($blueutil --discov)" '0' 85 | assert_eq "$($blueutil --discoverable)" '0' 86 | 87 | # Combined 88 | $blueutil -p1 -d1 89 | assert_eq "$($blueutil)" $'Power: 1\nDiscoverable: 1' 90 | 91 | $blueutil -p0 -d1 92 | assert_eq "$($blueutil)" $'Power: 0\nDiscoverable: 1' 93 | 94 | $blueutil -pon -dOff 95 | assert_eq "$($blueutil)" $'Power: 1\nDiscoverable: 0' 96 | 97 | $blueutil -pOFF -doFF 98 | assert_eq "$($blueutil)" $'Power: 0\nDiscoverable: 0' 99 | 100 | # Help 101 | assert_eq "$($blueutil --help)" *'this help'* 102 | assert_eq "$($blueutil -h)" *'this help'* 103 | 104 | # Version 105 | assert_match "$($blueutil --version)" '^[0-9]+\.[0-9]+\.[0-9]+$' 106 | assert_match "$($blueutil -v)" '^[0-9]+\.[0-9]+\.[0-9]+$' 107 | -------------------------------------------------------------------------------- /update_usage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | 5 | USAGE_REGEXP = / 6 | (?#{Regexp.escape ''}) 7 | .* 8 | (?#{Regexp.escape ''}) 9 | /mx 10 | 11 | path = Pathname('README.md') 12 | 13 | original = path.read 14 | changed = original.sub(USAGE_REGEXP) do 15 | m = Regexp.last_match 16 | system 'make -s blueutil' 17 | usage = `./blueutil --help`.sub(/\Ablueutil v.*/, '').lstrip 18 | "#{m[:open]}\n```\n#{usage}```\n#{m[:close]}" 19 | end 20 | 21 | path.open('w'){ |f| f.write(changed) } unless original == changed 22 | -------------------------------------------------------------------------------- /verify_release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | gem 'rugged', '~> 1.1' 4 | gem 'rubyzip', '~> 2.3' 5 | 6 | require 'rubygems/package' 7 | require 'rugged' 8 | require 'zip' 9 | 10 | class Release 11 | class Base 12 | attr_reader :tag, :name 13 | 14 | def initialize(tag) 15 | @tag = tag 16 | @name = tag.sub(/^v/, 'blueutil-') 17 | end 18 | end 19 | 20 | class Repo < Base 21 | def files 22 | @files ||= begin 23 | repo = Rugged::Repository.new('.') 24 | ref = repo.tags[tag] 25 | abort "No such tag #{tag}" unless ref 26 | ref.target.tree.walk(:postorder).map do |root, entry| 27 | next unless entry[:type] == :blob 28 | ["#{name}/#{root}#{entry[:name]}", repo.lookup(entry[:oid]).read_raw.data] 29 | end.compact.to_h 30 | end 31 | end 32 | end 33 | 34 | class Archive < Base 35 | def basename 36 | "#{name}#{extname}" 37 | end 38 | 39 | def url 40 | "https://github.com/toy/blueutil/archive/refs/tags/#{tag}#{extname}" 41 | end 42 | 43 | def data 44 | @data ||= IO.popen(%W[curl -sL #{url}], &:read) 45 | end 46 | end 47 | 48 | class Gzip < Archive 49 | def extname 50 | '.tar.gz' 51 | end 52 | 53 | def files 54 | @files ||= Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(data))).map do |entry| 55 | next if entry.directory? 56 | next if entry.full_name == 'pax_global_header' 57 | [entry.full_name, entry.read] 58 | end.compact.to_h 59 | end 60 | end 61 | 62 | class Zip < Archive 63 | def extname 64 | '.zip' 65 | end 66 | 67 | def files 68 | @files ||= ::Zip::File.open_buffer(StringIO.new(data)).entries.map do |entry| 69 | next if entry.directory? 70 | [entry.name, entry.get_input_stream.read] 71 | end.compact.to_h 72 | end 73 | end 74 | 75 | attr_reader :tag 76 | 77 | def initialize(tag) 78 | @tag = tag 79 | end 80 | 81 | def repo 82 | @repo ||= Repo.new(tag) 83 | end 84 | 85 | def archives 86 | @archives ||= [Gzip.new(tag), Zip.new(tag)] 87 | end 88 | 89 | def check! 90 | archives.each do |archive| 91 | next if repo.files == archive.files 92 | 93 | $stderr.puts "#{archive.basename} didn't match:" 94 | (repo.files.keys | archive.files.keys).sort.each do |path| 95 | $stderr.puts "#{path} #{repo.files[path] == archive.files[path] ? 'equal' : 'not equal'}" 96 | end 97 | 98 | abort 99 | end 100 | end 101 | 102 | def print 103 | puts '````' 104 | archives.each do |archive| 105 | puts archive.basename 106 | puts " sha1 #{Digest::SHA1.hexdigest(archive.data)}" 107 | puts " sha256 #{Digest::SHA256.hexdigest(archive.data)}" 108 | end 109 | puts '````' 110 | end 111 | end 112 | 113 | (ARGV.empty? ? [`git describe --tags --abbrev=0`.strip] : ARGV).each do |tag| 114 | release = Release.new(tag) 115 | 116 | release.check! 117 | 118 | release.print 119 | end 120 | --------------------------------------------------------------------------------