diff --git a/.gitignore b/.gitignore index a47fb1d..6f2c584 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .idea -__pycache__ \ No newline at end of file +__pycache__ +*.pkg.tar.zst +/pkg +/src +/AirStatusLinux diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..9bee22c --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,35 @@ +# Maintainer: Mansour Behabadi +# modified by blackbunt + +pkgname=airstatus-git +pkgver=20230124.b123b95 +pkgrel=1 +pkgdesc="Check AirPods battery levels on Linux" +arch=('i686' 'x86_64') +url="https://github.com/blackbunt/AirStatusLinux" +license=('GPL') +depends=('python36' 'python-bleak') +makedepends=('git') +provides=('airstatus') +conflicts=('airstatus') +source=("git+https://github.com/blackbunt/AirStatusLinux.git" + "airstatus.service" + "time_ns.py" + ) +sha256sums=('SKIP' + '13ea0ae4760febf5b5f01cc2c64e39ede61ba6cce3514d3c6e17cebe2b574ebc' + 'aad9238ddaae6de9cfe57e643485da440af65de9fd86140ed9a90e9b0ca533d7') + +pkgver() { + cd AirStatusLinux + printf "%s.%s" "$(git show -s --format=%cs | tr -d -)" "$(git rev-parse --short HEAD)" +} + +package() { + install -Dm644 airstatus.service -t "${pkgdir}/usr/lib/systemd/system" + + cd AirStatusLinux + install -Dm644 main.py "${pkgdir}/usr/lib/airstatus.py" + install -Dm644 time_ns.py "${pkgdir}/usr/lib/time_ns.py" + install -Dm644 LICENSE -t "${pkgdir}/usr/share/licenses/airstatus" +} diff --git a/README.md b/README.md index a7a2a95..8500938 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # **AirStatus for Linux** #### Check your AirPods battery level on Linux +forked from [delphiki/AirStatus](https://github.com/delphiki/AirStatus), I addded PKGBUILD for arch and updated the main file for newer Airpod models #### What is it? -This is a Python 3.6 script, forked from [faglo/AirStatus](https://github.com/faglo/AirStatus) that allows you to check AirPods battery level from your terminal, as JSON output. +This is a Python 3.6 script, forked from [faglo/AirStatus](https://github.com/faglo/AirStatus) that allows you to check AirPods battery level from your terminal, as JSON output. ### Usage @@ -17,6 +18,22 @@ Output will be stored in `output_file` if specified. ``` {"status": 1, "charge": {"left": 95, "right": 95, "case": -1}, "charging_left": false, "charging_right": false, "charging_case": false, "model": "AirPodsPro", "date": "2021-12-22 11:09:05"} ``` +### Installing AirStatus as a service on Arch with Pacman + +clone the Repo, make package and install the package + +``` +git clone https://github.com/blackbunt/AirStatusLinux +cd AirStatusLinux +makepkg +sudo pacman -U .pkg.tar.zst +``` +output of the service is located here: + +``` +/tmp/airstatus.out +``` + ### Installing as a service diff --git a/airpods.sh b/airpods.sh new file mode 100755 index 0000000..dbe3cd3 --- /dev/null +++ b/airpods.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# +# needs AirStatus installed and running +# +# displays airpod info +# get airpod device info +# get last line of output from /tmp/airstatus.out +function get_airpod_info { + airstatus=$(tail -n 1 /tmp/airstatus.out) + case=$(echo $airstatus | jq -r '.charge | .case') + left=$(echo $airstatus | jq -r '.charge | .left') + right=$(echo $airstatus | jq -r '.charge | .right') + ch_case=$(echo $airstatus | jq -r '.charging_case') + ch_left=$(echo $airstatus | jq -r '.charging_left') + ch_right=$(echo $airstatus | jq -r '.charging_right') + model=$(echo $airstatus | jq -r '.model') + date=$(echo $airstatus | jq -r '.date') + echo $left $right $case $ch_left $ch_right $ch_case $model $date +} + +# if value is -1 then return " " +function fix_value { + value=$1 + # if value is -1 + if [ $value -eq -1 ]; then + value=" " + echo $value + # if value is gretaer equal than 1 and smaller equal to 100 + elif [ $value -ge 1 ] && [ $value -le 100 ]; then + value=$(echo $value | awk '{printf "%3d", $1}') + echo $value + fi +} + +function get_state { + #if value is "true" than return 1 + #if value is "false" than return 0 + value=$1 + if [ "$value" = "true" ]; then + echo 1 + elif [ "$value" = "false" ]; then + echo 0 + fi +} + +function display_airpods { + # has parameter for left, right, case + # has parameter for charging_left_charging_right, charging_case + # has parameter for model + # has parameter for date + data=$(get_airpod_info) + left=$(fix_value $(echo $data | awk '{print $1}')) + right=$(fix_value $(echo $data | awk '{print $2}')) + case=$(fix_value $(echo $data | awk '{print $3}')) + ch_left=$(echo $data | awk '{print $4}') + ch_right=$(echo $data | awk '{print $5}') + ch_case=$(echo $data | awk '{print $6}') + model=$(echo $data | awk '{print $7}') + date=$(echo $data | awk '{print $8}') + + # if get_state is 1 than return ⚡️ + # if get_state is 0 than return " " + if [ $(get_state $ch_left) -eq 1 ]; then + ch_left="⚡️" + elif [ $(get_state $ch_left) -eq 0 ]; then + ch_left=" " + fi + + if [ $(get_state $ch_right) -eq 1 ]; then + ch_right="⚡️" + elif [ $(get_state $ch_right) -eq 0 ]; then + ch_right=" " + fi + + if [ $(get_state $ch_case) -eq 1 ]; then + ch_case="⚡️" + elif [ $(get_state $ch_case) -eq 0 ]; then + ch_case=" " + fi + + #echo $(get_airpod_info) + + # if model is AirPodsPro2 + if [ "$model" = "AirPodsPro2" ]; then + echo "" + echo "" + echo "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC4nYF5eXl5eXl5eXl5eXl5eXl5eXmAnLiAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC5eIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiJeYCAgCiAgICcuICcnICAgICAgICAgIC5gICAnICAgICAgICAuIl5eLCwsLCwsLCwsLCwsLCwsLCwsLCwsImBeXiAKICAnYCh4ISIsYCAgICAgIGAsOjpceCInLiAgICAgIGAsXiI6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6ImAiYAogLl5gPi9fOiwiYGAuIF5gIiI6IXQtYGAuICAgICAgYCJeLDo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6OjosXixgCiAgJywsImk7LCw+fSwuWy0sLCwhLF4iYCAgICAgICBgIl4sOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6OixeLGAKICAgIF5pPDohPmk6ICBeaT5pOzxpXiAgICAgICAgIGAiXiw6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Il4sYAogICAgJzpJICAgICAgICAgICAgbDouICAgICAgICAgYCxeLDo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6OjosXixgCiAgICBgO0kuICAgICAgICAgIC5JSScgICAgICAgICAuOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Oi4KICAgIGBpSS4gICAgICAgICAgLmxpYCAgICAgICAgICAnO2whIWxsbGxsbGxsbGxsbGxsbGxsbCEhbDsnIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYDpJIWlpaWlpaWlpaWlpaWlpaWkhSTpgICAgCg==" \ + | base64 --decode + echo "" + echo " $ch_left $left % $ch_right $right % $ch_case $case %" + echo "" + # if model is Airpods1 + elif [ "$model" = "AirPods1" ]; then + echo "" + echo "" + echo "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAuLicnJycnJycnJycnJycnJy4gICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYGAnJycnJycnJycnJycnYGBgYGAuICAgCiAgIC4gLicuLCcgICAgICxpLicuICAgICAgICAgICAgICAgIF5eLidgYGBgYGBgYGBgYGBgYGAgJyIuICAKICA6J0k6JydeXi4gICAnIiwuYF5sYCAgICAgICAgICAgICAgImAuYGBgYGBgYGBgYGBgYGBgXi4nIicgIAogIDo6aWwsSSwnYC4gIGAuJzo6O2ksLiAgICAgICAgICAgICAiYC5gYGBgYGBgYGBgYGBgYF5eLiciJyAgCiAgIF4sO0lJLF5gJyAuXmBebEk6YC4gICAgICAgICAgICAgICJgLmBgYGBgYGBgYF5eXl5eXl4uJyInICAKICAgICAgICAuXl5gIC5eXicgICAgICAgICAgICAgICAgICAgImAuYGBgYGBgYGBgXl5eXl5eXi4nIicgIAogICAgICAgIC5eXmAgLiJeJyAgICAgICAgICAgICAgICAgICAiYC5gYGBgYGBgYF5eXl5eXl5eLiciJyAgCiAgICAgICAgLl5eYCAuIl4nICAgICAgICAgICAgICAgICAgICJgLl5gYGBgYGBeXl5eXl5eXl4uJyInICAKICAgICAgICAuXl5gIC5eYCcgICAgICAgICAgICAgICAgICAgIl4uXmBgYGBgXl5eXl5eXl5eXi4nIicgIAogICAgICAgIC4iLF4gLiwsYCAgICAgICAgICAgICAgICAgICAiXi5gYGBgYGBgYGBgYGBgXl5eLiciLiAgCiAgICAgICAgJywhOiAuSTw6ICAgICAgICAgICAgICAgICAgIGAsYF5eXl5eXl5eXl5eXl5eXl5gIiIgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGA6Ozs7Ozs7Ozs7Ozs7Ozs7OjpeICAgIAo=" \ + | base64 --decode + echo "" + echo " $ch_left $left % $ch_right $right % $ch_case $case %" + echo "" + fi +} + +function is_connected { + # get bluetooth device name and check if it is AirPods...... + bluetooth=$(bluetoothctl info | grep Name | grep -oP '(?<=Name: ).*' | grep -oP '(^AirPods)') + #echo $bluetooth + if [ "$bluetooth" = "AirPods" ]; then + return 1 + else + return 0 + fi +} + +# if is_connected returns 1 then display_airpods +# if is_connected returns 0 then echo "AirPods: disconnected" +if is_connected; then + echo "AirPods: disconnected" +else + display_airpods +fi \ No newline at end of file diff --git a/airstatus.service b/airstatus.service new file mode 100644 index 0000000..8162c87 --- /dev/null +++ b/airstatus.service @@ -0,0 +1,11 @@ +[Unit] +Description=AirPods Battery Monitor + +[Service] +ExecStart=/usr/bin/python3 /usr/lib/airstatus.py /tmp/airstatus.out +User=nobody +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/get_model.py b/get_model.py new file mode 100644 index 0000000..1f3a355 --- /dev/null +++ b/get_model.py @@ -0,0 +1,141 @@ +from bleak import discover +from asyncio import new_event_loop, set_event_loop, get_event_loop +from time import sleep +from binascii import hexlify +from json import dumps +from sys import argv +from datetime import datetime +import time_ns + +# Configure update duration (update after n seconds) +UPDATE_DURATION = 1 +MIN_RSSI = -60 +AIRPODS_MANUFACTURER = 76 +AIRPODS_DATA_LENGTH = 54 +RECENT_BEACONS_MAX_T_NS = 10000000000 # 10 Seconds + +recent_beacons = [] + + +def get_best_result(device): + recent_beacons.append({ + "time": time_ns.time_ns(), + "device": device + }) + strongest_beacon = None + i = 0 + while i < len(recent_beacons): + if (time_ns.time_ns() - recent_beacons[i]["time"] > RECENT_BEACONS_MAX_T_NS): + recent_beacons.pop(i) + continue + if (strongest_beacon == None or strongest_beacon.rssi < recent_beacons[i]["device"].rssi): + strongest_beacon = recent_beacons[i]["device"] + i += 1 + + if (strongest_beacon != None and strongest_beacon.address == device.address): + strongest_beacon = device + + return strongest_beacon + + +# Getting data with hex format +async def get_device(): + # Scanning for devices + devices = await discover() + for d in devices: + # Checking for AirPods + d = get_best_result(d) + if d.rssi >= MIN_RSSI and AIRPODS_MANUFACTURER in d.metadata['manufacturer_data']: + data_hex = hexlify(bytearray(d.metadata['manufacturer_data'][AIRPODS_MANUFACTURER])) + data_length = len(hexlify(bytearray(d.metadata['manufacturer_data'][AIRPODS_MANUFACTURER]))) + if data_length == AIRPODS_DATA_LENGTH: + return data_hex + return False + + +# Same as get_device() but it's standalone method instead of async +def get_data_hex(): + new_loop = new_event_loop() + set_event_loop(new_loop) + loop = get_event_loop() + a = loop.run_until_complete(get_device()) + loop.close() + return a + + +# Getting data from hex string and converting it to dict(json) +# Getting data from hex string and converting it to dict(json) +def get_data(): + raw = get_data_hex() + + # Return blank data if airpods not found + if not raw: + return dict(status=0, model="AirPods not found") + + flip: bool = is_flipped(raw) + + # On 7th position we can get AirPods model, gen1, gen2, Pro or Max + if chr(raw[7]) == 'e': + model = "AirPodsPro" + elif chr(raw[7]) == 'f': + model = "AirPods2" + elif chr(raw[7]) == '2': + model = "AirPods1" + elif chr(raw[7]) == 'a': + model = "AirPodsMax" + elif chr(raw[7]) == '4': + model = "AirPodsPro2" + else: + model = f"unknown model, edit the main file @ line 77++, character = {chr(raw[7])}" + + # Checking left AirPod for availability and storing charge in variable + status_tmp = int("" + chr(raw[12 if flip else 13]), 16) + left_status = (100 if status_tmp == 10 else (status_tmp * 10 + 5 if status_tmp <= 10 else -1)) + + # Checking right AirPod for availability and storing charge in variable + status_tmp = int("" + chr(raw[13 if flip else 12]), 16) + right_status = (100 if status_tmp == 10 else (status_tmp * 10 + 5 if status_tmp <= 10 else -1)) + + # Checking AirPods case for availability and storing charge in variable + status_tmp = int("" + chr(raw[15]), 16) + case_status = (100 if status_tmp == 10 else (status_tmp * 10 + 5 if status_tmp <= 10 else -1)) + + # On 14th position we can get charge status of AirPods + charging_status = int("" + chr(raw[14]), 16) + charging_left: bool = (charging_status & (0b00000010 if flip else 0b00000001)) != 0 + charging_right: bool = (charging_status & (0b00000001 if flip else 0b00000010)) != 0 + charging_case: bool = (charging_status & 0b00000100) != 0 + + # Return result info in dict format + return dict(status=1, + model=model, + date=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + raw=raw.decode("utf-8") + ) + + +# Return if left and right is flipped in the data +def is_flipped(raw): + return (int("" + chr(raw[10]), 16) & 0x02) == 0 + + +def run(): + output_file = argv[-1] + + while True: + data = get_data() + + if data["status"] == 1: + json_data = dumps(data) + if len(argv) > 1: + f = open(output_file, "a") + f.write(json_data + "\n") + f.close() + else: + print(json_data) + + sleep(UPDATE_DURATION) + + +if __name__ == '__main__': + run() diff --git a/main.py b/main.py index a6b2e4f..aa7f082 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,11 @@ from bleak import discover from asyncio import new_event_loop, set_event_loop, get_event_loop -from time import sleep, time_ns +from time import sleep from binascii import hexlify from json import dumps from sys import argv from datetime import datetime +import time_ns # Configure update duration (update after n seconds) UPDATE_DURATION = 1 @@ -18,13 +19,13 @@ def get_best_result(device): recent_beacons.append({ - "time": time_ns(), + "time": time_ns.time_ns(), "device": device }) strongest_beacon = None i = 0 while i < len(recent_beacons): - if(time_ns() - recent_beacons[i]["time"] > RECENT_BEACONS_MAX_T_NS): + if(time_ns.time_ns() - recent_beacons[i]["time"] > RECENT_BEACONS_MAX_T_NS): recent_beacons.pop(i) continue if (strongest_beacon == None or strongest_beacon.rssi < recent_beacons[i]["device"].rssi): @@ -82,6 +83,8 @@ def get_data(): model = "AirPods1" elif chr(raw[7]) == 'a': model = "AirPodsMax" + elif chr(raw[7]) == '4': + model = "AirPodsPro2" else: model = "unknown" diff --git a/src/airstatus.service b/src/airstatus.service new file mode 120000 index 0000000..0ef8d04 --- /dev/null +++ b/src/airstatus.service @@ -0,0 +1 @@ +/home/bernie/github/AirStatusLinux/airstatus.service \ No newline at end of file diff --git a/time_ns.py b/time_ns.py new file mode 100644 index 0000000..e78bf2f --- /dev/null +++ b/time_ns.py @@ -0,0 +1,20 @@ +import ctypes + +CLOCK_REALTIME = 0 + +class timespec(ctypes.Structure): + _fields_ = [ + ('tv_sec', ctypes.c_int64), # seconds, https://stackoverflow.com/q/471248/1672565 + ('tv_nsec', ctypes.c_int64), # nanoseconds + ] + +clock_gettime = ctypes.cdll.LoadLibrary('libc.so.6').clock_gettime +clock_gettime.argtypes = [ctypes.c_int64, ctypes.POINTER(timespec)] +clock_gettime.restype = ctypes.c_int64 + +def time_ns(): + tmp = timespec() + ret = clock_gettime(CLOCK_REALTIME, ctypes.pointer(tmp)) + if bool(ret): + raise OSError() + return tmp.tv_sec * 10 ** 9 + tmp.tv_nsec \ No newline at end of file