Skip to content

Commit 29fb920

Browse files
authored
Merge pull request #66 from GaLaXy102/master
Create an interface to this ~~script~~ library
2 parents 08350c2 + 1823c13 commit 29fb920

File tree

3 files changed

+197
-104
lines changed

3 files changed

+197
-104
lines changed

Readme.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ You need python 3.6 or newer to run the script.
66

77
# ▶️ How to run
88

9-
### There are three options:
9+
### There are four options:
1010

1111
### Option 1: Install from PyPI
1212
Please ensure you have the BlueZ and python libraries and header files if you are using Ubuntu/Debian based distros:
@@ -60,6 +60,52 @@ docker run --rm -ti --privileged --net=host bluetooth_battery_level "BT_MAC_ADDR
6060

6161
--------
6262

63+
### Option 4: AUR
64+
65+
You must have AUR access enabled on your Arch or Manjaro machine.
66+
You can install this library using
67+
68+
```bash
69+
yay -S bluetooth-headset-battery-level-git
70+
```
71+
72+
--------
73+
74+
### Library usage
75+
76+
To use this as a library, simply install it using pip or AUR (see above) or require it in your Pipfile.
77+
You can then
78+
```python
79+
from bluetooth_battery import BatteryStateQuerier, BatteryQueryError, BluetoothError
80+
# only for error handling
81+
```
82+
and query the Battery State as follows:
83+
```python
84+
# Autodetects SPP port
85+
query = BatteryStateQuerier("11:22:33:44:55:66")
86+
# or with given port
87+
query = BatteryStateQuerier("11:22:33:44:55:66", "4")
88+
89+
result = int(query) # returns integer between 0 and 100
90+
# or
91+
result = str(query) # returns "0%".."100%"
92+
```
93+
94+
As errors can occur in a wireless system, you probably want to handle them:
95+
96+
```python
97+
try:
98+
query = BatteryStateQuerier("11:22:33:44:55:66") # Can raise BluetoothError when autodetecting port
99+
str(query) # Can raise BluetoothError when device is down or port is wrong
100+
# Can raise BatteryQueryError when the device is unsupported
101+
except BluetoothError as e:
102+
# Handle device is offline
103+
...
104+
except BatteryQueryError as e:
105+
# Handle device is unsupported
106+
...
107+
```
108+
63109
### GNOME Extension
64110

65111
There is also a GNOME extension for integrating this program with GNOME desktop environment:
@@ -106,6 +152,7 @@ You can open a new issue for discussion or check the existing ones for more info
106152
## Tested on
107153

108154
- [x] ArchLinux (5.6.14)
155+
- [x] Manjaro (5.14.10)
109156
- [x] NixOS 20.09 (20.09.2386.ae1b121d9a6)
110157
- [x] Debian GNU/Linux (bullseye 5.9)
111158
- [x] Ubuntu/Linux (Focal Fossa 20.04.1)

__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .bluetooth_battery import BatteryStateQuerier, BatteryQueryError
2+
from bluetooth import BluetoothError

bluetooth_battery.py

Lines changed: 147 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,163 @@
11
#!/usr/bin/env python3
22

33
"""
4-
A python script to get battery level from Bluetooth headsets
4+
A python library to get battery level from Bluetooth headsets
55
"""
66

77
# License: GPL-3.0
8-
# Author: @TheWeirdDev
8+
# Author: @TheWeirdDev, @GaLaXy102
99
# 29 Sept 2019
1010

11-
import sys
11+
import argparse
1212
import bluetooth
13+
from typing import Optional, Union, List, Dict
14+
15+
16+
class BatteryQueryError(bluetooth.BluetoothError):
17+
pass
18+
19+
20+
class SocketDataIterator:
21+
def __init__(self, sock: bluetooth.BluetoothSocket, chunk_size: int = 128):
22+
"""
23+
Create an Iterator over the given Socket
24+
25+
chunk_size defines the amount of data in Bytes to be read per iteration
26+
"""
27+
self._sock = sock
28+
self._chunk_size = chunk_size
29+
30+
def __next__(self):
31+
"""
32+
Receive chunks
33+
"""
34+
return self._sock.recv(self._chunk_size)
35+
36+
37+
class RFCOMMSocket(bluetooth.BluetoothSocket):
38+
39+
def __init__(self, proto=bluetooth.RFCOMM, _sock=None):
40+
super().__init__(proto, _sock)
41+
42+
def __iter__(self):
43+
"""
44+
Iterate over incoming chunks of 128 Bytes
45+
"""
46+
return SocketDataIterator(self)
47+
48+
@staticmethod
49+
def find_rfcomm_port(device_mac) -> int:
50+
"""
51+
Find the RFCOMM port number for a given bluetooth device
52+
"""
53+
uuid = "0000111e-0000-1000-8000-00805f9b34fb"
54+
services: List[Dict] = bluetooth.find_service(address=device_mac, uuid=uuid)
55+
56+
for service in services:
57+
if "protocol" in service.keys() and service["protocol"] == "RFCOMM":
58+
return service["port"]
59+
# Raise Interface error when the required service is not offered my the end device
60+
raise bluetooth.BluetoothError("Couldn't find the RFCOMM port number. Perhaps the device is offline?")
61+
62+
def send(self, data):
63+
"""
64+
This function sends a message through a bluetooth socket with added line separators
65+
"""
66+
return super().send(b"\r\n" + data + b"\r\n")
67+
68+
69+
class BatteryStateQuerier:
70+
71+
def __init__(self, bluetooth_mac: str, bluetooth_port: Optional[Union[str, int]] = None):
72+
"""
73+
Prepare a query for the end devices' battery state
74+
75+
bluetooth_mac is the MAC of the end device, e.g. 11:22:33:44:55:66
76+
bluetooth_port is the Port of the RFCOMM/SPP service of the end device.
77+
It will be determined automatically if not given.
78+
79+
The actual query can be performed using the int() and str() method.
80+
"""
81+
self._bt_settings = bluetooth_mac, int(bluetooth_port or RFCOMMSocket.find_rfcomm_port(bluetooth_mac))
82+
83+
def __int__(self):
84+
"""
85+
Perform a reading and get the result as int between 0 and 100
86+
"""
87+
return self._perform_query()
88+
89+
def __str__(self):
90+
"""
91+
Perform a reading and get the result as str between 0% and 100%
92+
"""
93+
return "{:.0%}".format(self._perform_query() / 100)
94+
95+
def _perform_query(self) -> int:
96+
"""
97+
Will try to get and print the battery level of supported devices
98+
"""
99+
result = None
100+
sock = RFCOMMSocket()
101+
sock.connect(self._bt_settings)
102+
# Iterate received packets until there is no more or a result was found
103+
for line in sock:
104+
if b"BRSF" in line:
105+
sock.send(b"+BRSF: 1024")
106+
sock.send(b"OK")
107+
elif b"CIND=" in line:
108+
sock.send(b"+CIND: (\"battchg\",(0-5))")
109+
sock.send(b"OK")
110+
elif b"CIND?" in line:
111+
sock.send(b"+CIND: 5")
112+
sock.send(b"OK")
113+
elif b"BIND=?" in line:
114+
# Announce that we support the battery level HF indicator
115+
# https://www.bluetooth.com/specifications/assigned-numbers/hands-free-profile/
116+
sock.send(b"+BIND: (2)")
117+
sock.send(b"OK")
118+
elif b"BIND?" in line:
119+
# Enable battery level HF indicator
120+
sock.send(b"+BIND: 2,1")
121+
sock.send(b"OK")
122+
elif b"XAPL=" in line:
123+
sock.send(b"+XAPL=iPhone,7")
124+
sock.send(b"OK")
125+
elif b"IPHONEACCEV" in line:
126+
parts = line.strip().split(b',')[1:]
127+
if len(parts) > 1 and (len(parts) % 2) == 0:
128+
parts = iter(parts)
129+
params = dict(zip(parts, parts))
130+
if b'1' in params:
131+
result = (int(params[b'1']) + 1) * 10
132+
break
133+
elif b"BIEV=" in line:
134+
params = line.strip().split(b"=")[1].split(b",")
135+
if params[0] == b"2":
136+
result = int(params[1])
137+
break
138+
elif b"XEVENT=BATTERY" in line:
139+
params = line.strip().split(b"=")[1].split(b",")
140+
result = int(params[1]) / int(params[2]) * 100
141+
break
142+
else:
143+
sock.send(b"OK")
144+
sock.close()
145+
# Check whether the result was found, otherwise raise an Error
146+
if result is None:
147+
raise BatteryQueryError("Could not query the battery state.")
148+
return result
13149

14150

15-
def send(sock, message):
16-
"""
17-
This function sends a message through a bluetooth socket
18-
"""
19-
sock.send(b"\r\n" + message + b"\r\n")
20-
21-
22-
def get_at_command(sock, line, device):
23-
"""
24-
Will try to get and print the battery level of supported devices
25-
"""
26-
blevel = -1
27-
28-
if b"BRSF" in line:
29-
send(sock, b"+BRSF: 1024")
30-
send(sock, b"OK")
31-
elif b"CIND=" in line:
32-
send(sock, b"+CIND: (\"battchg\",(0-5))")
33-
send(sock, b"OK")
34-
elif b"CIND?" in line:
35-
send(sock, b"+CIND: 5")
36-
send(sock, b"OK")
37-
elif b"BIND=?" in line:
38-
# Announce that we support the battery level HF indicator
39-
# https://www.bluetooth.com/specifications/assigned-numbers/hands-free-profile/
40-
send(sock, b"+BIND: (2)")
41-
send(sock, b"OK")
42-
elif b"BIND?" in line:
43-
# Enable battery level HF indicator
44-
send(sock, b"+BIND: 2,1")
45-
send(sock, b"OK")
46-
elif b"XAPL=" in line:
47-
send(sock, b"+XAPL=iPhone,7")
48-
send(sock, b"OK")
49-
elif b"IPHONEACCEV" in line:
50-
parts = line.strip().split(b',')[1:]
51-
if len(parts) > 1 and (len(parts) % 2) == 0:
52-
parts = iter(parts)
53-
params = dict(zip(parts, parts))
54-
if b'1' in params:
55-
blevel = (int(params[b'1']) + 1) * 10
56-
elif b"BIEV=" in line:
57-
params = line.strip().split(b"=")[1].split(b",")
58-
if params[0] == b"2":
59-
blevel = int(params[1])
60-
elif b"XEVENT=BATTERY" in line:
61-
params = line.strip().split(b"=")[1].split(b",")
62-
blevel = int(params[1]) / int(params[2]) * 100
63-
else:
64-
send(sock, b"OK")
65-
66-
if blevel != -1:
67-
print(f"Battery level for {device} is {blevel}%")
68-
return False
69-
70-
return True
71-
72-
73-
def find_rfcomm_port(device):
74-
"""
75-
Find the RFCOMM port number for a given bluetooth device
76-
"""
77-
uuid = "0000111e-0000-1000-8000-00805f9b34fb"
78-
proto = bluetooth.find_service(address=device, uuid=uuid)
79-
if len(proto) == 0:
80-
print("Couldn't find the RFCOMM port number")
81-
return 4
82-
83-
for pr in proto:
84-
if 'protocol' in pr and pr['protocol'] == 'RFCOMM':
85-
port = pr['port']
86-
return port
87-
return 4
88-
89-
90-
def main():
151+
if __name__ == "__main__":
91152
"""
92153
The starting point of the program. For each device address in the argument
93154
list a bluetooth socket will be opened and the battery level will be read
94155
and printed to stdout
95156
"""
96-
if len(sys.argv) < 2:
97-
print("Usage: bluetooth_battery.py BT_MAC_ADDRESS_1.PORT ...")
98-
print(" Port number is optional")
99-
sys.exit()
100-
else:
101-
for device in sys.argv[1:]:
102-
i = device.find('.')
103-
if i == -1:
104-
port = find_rfcomm_port(device)
105-
else:
106-
port = int(device[i+1:])
107-
device = device[:i]
108-
try:
109-
sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM)
110-
sock.connect((device, port))
111-
while get_at_command(sock, sock.recv(128), device):
112-
pass
113-
sock.close()
114-
except OSError as err:
115-
print(f"{device} is offline", err)
116-
117-
118-
if __name__ == "__main__":
119-
main()
157+
parser = argparse.ArgumentParser(description="Get battery level from Bluetooth headsets")
158+
parser.add_argument("devices", metavar="DEVICE_MAC[.PORT]", type=str, nargs="+",
159+
help="(MAC address of target)[.SPP Port]")
160+
args = parser.parse_args()
161+
for device in args.devices:
162+
query = BatteryStateQuerier(*device.split("."))
163+
print("Battery level for {} is {}".format(device, str(query)))

0 commit comments

Comments
 (0)