packages feed

taffybar-4.1.2: src/System/Taffybar/Information/Bluetooth.hs

{-# LANGUAGE OverloadedStrings #-}
-----------------------------------------------------------------------------
-- |
-- Module      : System.Taffybar.Information.Bluetooth
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- This module provides functions for querying Bluetooth information using the
-- BlueZ dbus interface (org.bluez), as well as a broadcast "TChan" system for
-- allowing multiple readers to receive 'BluetoothInfo' updates without
-- duplicating requests.
--
-- The module uses the DBus ObjectManager interface to dynamically discover
-- Bluetooth controllers and devices, and monitors property changes for
-- real-time updates.
-----------------------------------------------------------------------------
module System.Taffybar.Information.Bluetooth
  ( -- * Data Types
    BluetoothInfo(..)
  , BluetoothDevice(..)
  , BluetoothController(..)
  , BluetoothStatus(..)
    -- * Information Access
  , getBluetoothInfo
  , getBluetoothInfoChan
  , getBluetoothInfoState
    -- * Connection
  , connectBluez
  ) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, finally, try)
import Control.Monad (forever)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.STM (atomically)
import DBus
import DBus.Client
import Data.List (sortOn)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Word (Word8)
import System.Log.Logger (Priority(..))
import System.Taffybar.Context (TaffyIO, getStateDefault, systemDBusClient)
import System.Taffybar.Util (logPrintF)
import Control.Monad.Trans.Reader (asks)

-- | Information about a Bluetooth device.
data BluetoothDevice = BluetoothDevice
  { devicePath :: ObjectPath
  , deviceName :: String
  , deviceAlias :: String
  , deviceAddress :: String
  , deviceIcon :: Maybe String
  , deviceConnected :: Bool
  , devicePaired :: Bool
  , deviceTrusted :: Bool
  , deviceBlocked :: Bool
  , deviceBatteryPercentage :: Maybe Word8
  } deriving (Eq, Show)

-- | Information about a Bluetooth controller (adapter).
data BluetoothController = BluetoothController
  { controllerPath :: ObjectPath
  , controllerAlias :: String
  , controllerAddress :: String
  , controllerPowered :: Bool
  , controllerDiscoverable :: Bool
  , controllerDiscovering :: Bool
  , controllerPairable :: Bool
  } deriving (Eq, Show)

-- | Complete Bluetooth state information.
data BluetoothInfo = BluetoothInfo
  { bluetoothController :: Maybe BluetoothController
  , bluetoothConnectedDevices :: [BluetoothDevice]
  , bluetoothAllDevices :: [BluetoothDevice]
  , bluetoothStatus :: BluetoothStatus
  } deriving (Eq, Show)

-- | High-level Bluetooth status.
data BluetoothStatus
  = BluetoothNoController
  | BluetoothOff
  | BluetoothOn
  | BluetoothConnected
  deriving (Eq, Show)

bluetoothLogPath :: String
bluetoothLogPath = "System.Taffybar.Information.Bluetooth"

bluetoothLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
bluetoothLogF = logPrintF bluetoothLogPath

-- | BlueZ DBus constants
bluezBusName :: BusName
bluezBusName = "org.bluez"

bluezRootPath :: ObjectPath
bluezRootPath = "/"

objectManagerInterfaceName :: InterfaceName
objectManagerInterfaceName = "org.freedesktop.DBus.ObjectManager"

propertiesInterfaceName :: InterfaceName
propertiesInterfaceName = "org.freedesktop.DBus.Properties"

adapter1InterfaceName :: InterfaceName
adapter1InterfaceName = "org.bluez.Adapter1"

device1InterfaceName :: InterfaceName
device1InterfaceName = "org.bluez.Device1"

battery1InterfaceName :: InterfaceName
battery1InterfaceName = "org.bluez.Battery1"

-- | Newtype wrapper for the channel/mvar pair to enable getStateDefault.
newtype BluetoothInfoChanVar =
  BluetoothInfoChanVar (TChan BluetoothInfo, MVar BluetoothInfo)

-- | Get a broadcast channel for Bluetooth info updates.
--
-- The first call will start a monitoring thread that keeps the BlueZ DBus
-- connection open and refreshes on property changes. Subsequent calls return
-- the already created channel.
getBluetoothInfoChan :: TaffyIO (TChan BluetoothInfo)
getBluetoothInfoChan = do
  BluetoothInfoChanVar (chan, _) <- getBluetoothInfoChanVar
  pure chan

-- | Read the current Bluetooth info state.
getBluetoothInfoState :: TaffyIO BluetoothInfo
getBluetoothInfoState = do
  BluetoothInfoChanVar (_, var) <- getBluetoothInfoChanVar
  liftIO $ readMVar var

getBluetoothInfoChanVar :: TaffyIO BluetoothInfoChanVar
getBluetoothInfoChanVar =
  getStateDefault $ do
    client <- asks systemDBusClient
    liftIO $ do
      chan <- newBroadcastTChanIO
      var <- newMVar defaultBluetoothInfo
      _ <- forkIO $ monitorBluetoothInfo client chan var
      pure $ BluetoothInfoChanVar (chan, var)

defaultBluetoothInfo :: BluetoothInfo
defaultBluetoothInfo = BluetoothInfo
  { bluetoothController = Nothing
  , bluetoothConnectedDevices = []
  , bluetoothAllDevices = []
  , bluetoothStatus = BluetoothNoController
  }

-- | Monitor Bluetooth information changes.
monitorBluetoothInfo ::
  Client ->
  TChan BluetoothInfo ->
  MVar BluetoothInfo ->
  IO ()
monitorBluetoothInfo client chan var = do
  refreshLock <- newMVar ()
  let writeInfo info = do
        _ <- swapMVar var info
        atomically $ writeTChan chan info

      refreshUnlocked = do
        result <- try $ getBluetoothInfoFromClient client
        case result of
          Left (e :: SomeException) -> do
            bluetoothLogF WARNING "Bluetooth refresh failed: %s" e
            writeInfo defaultBluetoothInfo
          Right info -> writeInfo info

      refresh = withMVar refreshLock $ const refreshUnlocked

      -- Match rule for BlueZ property changes
      propertiesChangedMatcher :: MatchRule
      propertiesChangedMatcher = matchAny
        { matchSender = Just bluezBusName
        , matchInterface = Just propertiesInterfaceName
        , matchMember = Just "PropertiesChanged"
        }

      -- Match rule for ObjectManager signals
      interfacesAddedMatcher :: MatchRule
      interfacesAddedMatcher = matchAny
        { matchSender = Just bluezBusName
        , matchInterface = Just objectManagerInterfaceName
        , matchMember = Just "InterfacesAdded"
        }

      interfacesRemovedMatcher :: MatchRule
      interfacesRemovedMatcher = matchAny
        { matchSender = Just bluezBusName
        , matchInterface = Just objectManagerInterfaceName
        , matchMember = Just "InterfacesRemoved"
        }

      loop = do
        -- Initial refresh
        refresh

        let runWithClient = do
              -- Register signal handlers
              hProps <- addMatch client propertiesChangedMatcher (const refresh)
              hAdded <- addMatch client interfacesAddedMatcher (const refresh)
              hRemoved <- addMatch client interfacesRemovedMatcher (const refresh)

              let cleanup = do
                    removeMatch client hProps
                    removeMatch client hAdded
                    removeMatch client hRemoved

              -- Block forever until an exception occurs
              blockForever `finally` cleanup

        result <- try runWithClient
        case result of
          Left (e :: SomeException) ->
            bluetoothLogF WARNING "Bluetooth monitor error: %s" e
          Right _ -> pure ()

        -- Wait before retrying
        threadDelay 5000000
        loop

      blockForever = forever $ threadDelay 1000000000

  loop

-- | Get Bluetooth information from an existing DBus client.
getBluetoothInfoFromClient :: Client -> IO BluetoothInfo
getBluetoothInfoFromClient client = do
  managedObjects <- getManagedObjects client
  case managedObjects of
    Left err -> do
      bluetoothLogF WARNING "Failed to get BlueZ managed objects: %s" err
      return defaultBluetoothInfo
    Right objects -> do
      let controllers = parseControllers objects
          devices = parseDevices objects
          connectedDevices = filter deviceConnected devices
          controller = listToMaybe controllers
          status = case controller of
            Nothing -> BluetoothNoController
            Just c
              | not (controllerPowered c) -> BluetoothOff
              | not (null connectedDevices) -> BluetoothConnected
              | otherwise -> BluetoothOn
      return BluetoothInfo
        { bluetoothController = controller
        , bluetoothConnectedDevices = connectedDevices
        , bluetoothAllDevices = devices
        , bluetoothStatus = status
        }

-- | Get Bluetooth info using the system DBus client.
getBluetoothInfo :: Client -> IO BluetoothInfo
getBluetoothInfo = getBluetoothInfoFromClient

-- | Connect to BlueZ on the system bus.
connectBluez :: IO (Maybe Client)
connectBluez = do
  result <- try connectSystem
  case result of
    Left (_ :: SomeException) -> return Nothing
    Right client -> return (Just client)

-- | Get all managed objects from BlueZ ObjectManager.
getManagedObjects :: Client -> IO (Either MethodError (Map ObjectPath (Map Text (Map Text Variant))))
getManagedObjects client = do
  let callMsg = (methodCall bluezRootPath objectManagerInterfaceName "GetManagedObjects")
        { methodCallDestination = Just bluezBusName }
  reply <- call client callMsg
  return $ case reply of
    Left err -> Left err
    Right ret -> case listToMaybe (methodReturnBody ret) >>= fromVariant of
      Nothing -> Left $ methodError (methodReturnSerial ret) $
        errorName_ "org.taffybar.InvalidResponse"
      Just objects -> Right objects

-- | Parse controllers from managed objects.
parseControllers :: Map ObjectPath (Map Text (Map Text Variant)) -> [BluetoothController]
parseControllers objects =
  sortOn controllerPath $ mapMaybe parseController $ M.toList objects
  where
    parseController :: (ObjectPath, Map Text (Map Text Variant)) -> Maybe BluetoothController
    parseController (path, interfaces) = do
      props <- M.lookup (T.pack $ formatInterfaceName adapter1InterfaceName) interfaces
      let readProp :: IsVariant a => Text -> a -> a
          readProp key def = fromMaybe def $ M.lookup key props >>= fromVariant
      return BluetoothController
        { controllerPath = path
        , controllerAlias = readProp "Alias" ""
        , controllerAddress = readProp "Address" ""
        , controllerPowered = readProp "Powered" False
        , controllerDiscoverable = readProp "Discoverable" False
        , controllerDiscovering = readProp "Discovering" False
        , controllerPairable = readProp "Pairable" False
        }

-- | Parse devices from managed objects.
parseDevices :: Map ObjectPath (Map Text (Map Text Variant)) -> [BluetoothDevice]
parseDevices objects =
  sortOn devicePath $ mapMaybe parseDevice $ M.toList objects
  where
    parseDevice :: (ObjectPath, Map Text (Map Text Variant)) -> Maybe BluetoothDevice
    parseDevice (path, interfaces) = do
      props <- M.lookup (T.pack $ formatInterfaceName device1InterfaceName) interfaces
      let readProp :: IsVariant a => Text -> a -> a
          readProp key def = fromMaybe def $ M.lookup key props >>= fromVariant
          batteryProps = M.lookup (T.pack $ formatInterfaceName battery1InterfaceName) interfaces
          batteryPct = batteryProps >>= M.lookup "Percentage" >>= fromVariant
      return BluetoothDevice
        { devicePath = path
        , deviceName = readProp "Name" ""
        , deviceAlias = readProp "Alias" ""
        , deviceAddress = readProp "Address" ""
        , deviceIcon = M.lookup "Icon" props >>= fromVariant
        , deviceConnected = readProp "Connected" False
        , devicePaired = readProp "Paired" False
        , deviceTrusted = readProp "Trusted" False
        , deviceBlocked = readProp "Blocked" False
        , deviceBatteryPercentage = batteryPct
        }