Monitoring battery status on macOS with Rust

Created On:

Recently I wanted to execute code1 when my MacBook’s battery charge reached a certain percentage remaining. I was using the rust-battery crate to check the status of the battery of my MacBook. The crate comes with an example program called simple.rs which shows how to poll for the battery state. However the problem with this example program is the main loop.

loop {
    println!("{:?}", battery);
    thread::sleep(Duration::from_secs(1));
    manager.refresh(&mut battery)?;
}

The loop inside main of simple.rs

On my laptop simple.rs prints out the following:

Battery { impl: MacOSDevice { source: PowerSource { io_object: IoObject(2563) } }, vendor: Some("SMP"), model: Some("bq20z451"), serial_number: None, technology: Unknown, state: Discharging, capacity: 0.8180468, temperature: Some(303.96 K^1), percentage: 0.6009494, cycle_count: Some(731), energy: 164085.69 m^2 kg^1 s^-2, energy_full: 273044.1 m^2 kg^1 s^-2, energy_full_design: 333775.63 m^2 kg^1 s^-2, energy_rate: 30.065012 m^2 kg^1 s^-3, voltage: 10.59 m^2 kg^1 s^-3 A^-1, time_to_full: None, time_to_empty: Some(6300.0 s^1) }
Battery { impl: MacOSDevice { source: PowerSource { io_object: IoObject(2563) } }, vendor: Some("SMP"), model: Some("bq20z451"), serial_number: None, technology: Unknown, state: Discharging, capacity: 0.8180468, temperature: Some(303.96 K^1), percentage: 0.6009494, cycle_count: Some(731), energy: 164085.69 m^2 kg^1 s^-2, energy_full: 273044.1 m^2 kg^1 s^-2, energy_full_design: 333775.63 m^2 kg^1 s^-2, energy_rate: 30.065012 m^2 kg^1 s^-3, voltage: 10.59 m^2 kg^1 s^-3 A^-1, time_to_full: None, time_to_empty: Some(6300.0 s^1) }
Battery { impl: MacOSDevice { source: PowerSource { io_object: IoObject(2563) } }, vendor: Some("SMP"), model: Some("bq20z451"), serial_number: None, technology: Unknown, state: Discharging, capacity: 0.8180468, temperature: Some(303.96 K^1), percentage: 0.6009494, cycle_count: Some(731), energy: 164085.69 m^2 kg^1 s^-2, energy_full: 273044.1 m^2 kg^1 s^-2, energy_full_design: 333775.63 m^2 kg^1 s^-2, energy_rate: 30.065012 m^2 kg^1 s^-3, voltage: 10.59 m^2 kg^1 s^-3 A^-1, time_to_full: None, time_to_empty: Some(6300.0 s^1) }
...

Output of simple.rs from rust-battery

Each iteration of the loop results in the same output as the previous run, since the polling interval is more frequent than the battery data updates from macOS. A better program would only refresh the battery data once the OS has signalled some new information is available.

Fortunately on macOS it’s possible to be signalled when the battery state has been updated using the notify.h API in the macOS SDK. The rest of this post will show how to use notify.h safely from Rust and integrate it with Tokio so battery data can be refreshed asynchronously.

What is the notify.h API ?

The man page for this API is called notify(3). To quote from the man page:

These routines allow processes to exchange stateless notification events. Processes post notifications to a single system-wide notification server, which then distributes notifications to client processes that have registered to receive those notifications, including processes run by other users.

This API allows processes to subscribe or publish messages to a macOS wide event bus2. Each event is given a unique string identifier such as com.apple.system.config.network_change3. The event is published from a single process and arbitrary processes can subscribe to them. The typical use that I have seen for these APIs is subscribing to OS configuration changes such as network, timezone, hostname, power source and other changes4.

For subscribing to battery charge state or power source changes, the event name is com.apple.system.powersources.timeremaining. This event will be published if the machine switches between battery power and AC Power, and if there are any changes to the battery charge level.

The entire API is exposed in the notify.h header, and there are only a few functions.The entire lifecycle of subscribing to, receiving and unsubscribing from an event is covered by the following functions5.

/*
 * Request notification by a write to a file descriptor.
 *
 * Notifications are delivered by a write to a file descriptor.
 * By default, a new file descriptor is created and a pointer to it
 * is returned as the value of "notify_fd". A file descriptor created
 * by a previous call to this routine may be used for notifications if
 * a pointer to that file descriptor is passed in to the routine and
 * NOTIFY_REUSE is set in the flags parameter.
 *
 * Note that the kernel limits the buffer space for queued writes on a
 * file descriptor. If it is important that notifications should not be
 * lost due to queue overflow, clients should service messages quickly,
 * and be careful about using the same file descriptor for notifications
 * for more than one name.
 *
 * Notifications are delivered by an integer value written to the
 * file descriptor. The value will match the notification token
 * for which the notification was generated.
 */
OS_EXPORT uint32_t notify_register_file_descriptor(const char *name, int
 *notify_fd, int flags, int *out_token);
 /*
 * Cancel notification and free resources associated with a notification
 * token. Mach ports and file descriptor associated with a token are released
 * (deallocated or closed) when all registration tokens associated with
 * the port or file descriptor have been cancelled.
 */
OS_EXPORT uint32_t notify_cancel(int token);
/*
 * Suspend delivery of notifications for a token. Notifications for this token will be
 * pended and coalesced, then delivered following a matching call to notify_resume.
 * Calls to notify_suspend may be nested. Notifications remain suspended until
 * an equal number of calls have been made to notify_resume.
 */
OS_EXPORT uint32_t notify_suspend(int token);
/*
 * Removes one level of suspension for a token previously suspended
 * by a call to notify_suspend. Notifications will resume when a matching
 * call to notify_resume is made for each previous call to notify_suspend.
 * Notifications posted while a token is suspended are coalesced into
 * a single notification sent following a resumption.
 */
OS_EXPORT uint32_t notify_resume(int token);

Copied from notify.h in the macOS SDK

From the comments of the header it should be possible to do the following:

The file descriptor doesn’t represent a file on disk, so we should be careful to never write to it or to accidentally close it.

Creating safe bindings

The comments in notify.h hint on what a safe Rust API could look like. First a file descriptor and token are returned from notify_register_file_descriptor and this file descriptor can only be closed with notify_cancel. That tells us that a constructor in Rust would need to call notify_register_file_descriptor and return a struct with a Drop implementation that calls notify_cancel.

Second, the file descriptor returned is not backed by a file. Passing this file descriptor to File would be not appropriate since that offers methods that could write to the file descriptor. Instead, we likely need to implement the Read trait on our struct.

With out two hints, using the notify.h from safe Rust looks like something below.

extern crate battery;
// Need the libc crate to read from a raw file descriptor
extern crate libc;

use std::error::Error;
use std::ffi::CString;
use std::io::Read;
use std::mem::MaybeUninit;
use std::os::raw::{c_char, c_int, c_void};
use std::os::unix::io::RawFd;
use std::result::Result;

const KEY: &str = "com.apple.system.powersources.timeremaining";

struct NotifyFd {
    fd: RawFd,
    pub token: c_int,
}

impl NotifyFd {
    fn new(key: &str) -> Result<Self, Box<dyn Error>> {
        let mut token = MaybeUninit::<c_int>::uninit();
        let mut nfd = MaybeUninit::<RawFd>::uninit();
        unsafe {
            let key = CString::new(key).unwrap();
            let r = notify_register_file_descriptor(
                key.as_ptr(),
                nfd.as_mut_ptr(),
                0,
                token.as_mut_ptr(),
            );
            if r != 0 {
                return Err("notify_register_file_descriptor failed".into());
            }
        }
        let token = unsafe { token.assume_init() };
        let nfd = unsafe { nfd.assume_init() };

        return Ok(NotifyFd {
            fd: nfd,
            token: token,
        });
    }
}

impl Read for NotifyFd {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        unsafe {
            let r = libc::read(self.fd, buf.as_mut_ptr() as *mut c_void, buf.len());
            if r == -1 {
                return Err(std::io::Error::last_os_error());
            } else {
                return Ok(r as usize);
            }
        }
    }
}

impl Drop for NotifyFd {
    fn drop(&mut self) {
        unsafe {
            let r = notify_cancel(self.token);
            if r != 0 {
                panic!("notify_cancel failed");
            }
        }
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    let manager = battery::Manager::new()?;
    let mut battery = manager.batteries()?.next().ok_or("no battery found")??;

    let mut nfd = NotifyFd::new(KEY).unwrap();
    let mut buf = [0; 4];

    loop {
        println!("percent charge: {:?}", battery.state_of_charge());

        nfd.read_exact(&mut buf).unwrap();
        let v = c_int::from_be_bytes(buf);
        if v == nfd.token {
            manager.refresh(&mut battery)?;
        } else {
            return Err("Unknown token in file descriptor!".into());
        }
    }
}

extern "C" {
    pub fn notify_register_file_descriptor(
        name: *const c_char,
        notify_fd: *mut c_int,
        flags: c_int,
        out_token: *mut c_int,
    ) -> u32;

    pub fn notify_cancel(token: c_int) -> u32;
}

Using safe bindings to notify.h to signal when to refresh battery state

On my laptop I get the following output after sometime on battery power.

percent charge: 0.5685927
percent charge: 0.5514522
...

Output of using notify.h to poll for battery state changes

Observe that every line has a different value, indicating the program is not polling for battery state too frequently. The only drawback with this approach is that using the Read trait blocks the main until the next event arrives, preventing concurrency.

Adding async with Tokio

To prevent blocking while waiting for the next event, we can integrate the above NotifyFd struct with Tokio. Tokio, unlike other Rust async runtimes, allows for arbitrary file descriptors to be added to it’s reactor with AsyncFd. The documentation for AsyncFd says:

Associates an IO object backed by a Unix file descriptor with the tokio reactor, allowing for readiness to be polled. The file descriptor must be of a type that can be used with the OS polling facilities (ie, poll, epoll, kqueue, etc), such as a network socket or pipe, and the file descriptor must have the nonblocking mode set to true.

This implies so long as we correctly change the file descriptor to non blocking mode and pass it to Tokio, it should be possible to asynchronously wait for events. Based on the documentation of the trait the code needs to do the following:

A full implementation based on the code above is below. The primary additions is adding the AsRawFd trait to the NotifyFd struct, as well as a new struct called AsyncNotifyFd which implements the integration with Tokio. The main function is now async.

extern crate battery;
extern crate futures;
extern crate libc;
extern crate tokio;

use std::error::Error;
use std::ffi::CString;
use std::io::Read;
use std::mem::MaybeUninit;
use std::os::raw::{c_char, c_int, c_void};
use std::os::unix::io::AsRawFd;
use std::os::unix::io::RawFd;
use std::pin::Pin;
use std::result::Result;
use std::task::Context;
use std::task::Poll;

use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::io::ReadBuf;
use tokio::io::{AsyncRead, AsyncReadExt};

use futures::ready;

const KEY: &str = "com.apple.system.powersources.timeremaining";

struct NotifyFd {
    fd: RawFd,
    pub token: c_int,
}

impl NotifyFd {
    fn new(key: &str) -> Result<Self, Box<dyn Error>> {
      // Same as above ...
    }
}

impl Read for NotifyFd {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        // Same as above ...
    }
}

impl Drop for NotifyFd {
    fn drop(&mut self) {
      // Same as above ...
    }
}

// Needed for integration with Tokio
impl AsRawFd for NotifyFd {
    fn as_raw_fd(&self) -> RawFd {
        return self.fd;
    }
}


struct AsyncNotifyFd {
    inner: AsyncFd<NotifyFd>,
    pub token: c_int,
}

impl AsyncNotifyFd {
    fn new(key: &str) -> Result<Self, Box<dyn Error>> {
        let mut nfd = NotifyFd::new(key)?;

        // Suspend the events while we adjust the fd
        unsafe {
            let r = notify_suspend(nfd.token);
            if r != 0 {
                return Err("notify_suspend failed".into());
            }
        }

        // Set the file descriptor in non blocking mode
        unsafe {
            let flags = libc::fcntl(nfd.fd, libc::F_GETFL);
            let r = libc::fcntl(nfd.fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
            if r != 0 {
                return Err("fcntl failed".into());
            }
        }

        // Drain the file descriptor of all data before registering with Tokio
        loop {
            let mut buf = [0; 4];
            match nfd.read_exact(&mut buf) {
                Ok(_) => {
                    continue;
                }
                Err(e) => {
                    if e.kind() == std::io::ErrorKind::WouldBlock {
                        break;
                    } else {
                        return Err(format!("unexpected read io error {}", e).into());
                    }
                }
            }
        }

        let t = nfd.token;

        // Register the file descriptor with tokio
        let afd = AsyncFd::with_interest(nfd, Interest::READABLE)?;

        // Resume events
        unsafe {
            let r = notify_resume(t);
            if r != 0 {
                return Err("notify_resume failed".into());
            }
        }

        return Ok(Self {
            inner: afd,
            token: t,
        });
    }
}

impl AsyncRead for AsyncNotifyFd {
    fn poll_read(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<std::io::Result<()>> {
        loop {
            let mut guard = ready!(self.inner.poll_read_ready_mut(cx))?;
            let r = guard.try_io(|x| x.get_mut().read(buf.initialize_unfilled()));
            if r.is_ok() {
                return Poll::Ready(r.unwrap().map(|r| buf.advance(r)));
            }
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let manager = battery::Manager::new()?;
    let mut battery = manager.batteries()?.next().ok_or("no battery found")??;

    let mut nfd = AsyncNotifyFd::new(KEY)?;
    let mut buf = [0; 4];

    loop {
        println!("percent charge: {:?}", battery.state_of_charge());

        nfd.read_exact(&mut buf).await.unwrap();
        let v = c_int::from_be_bytes(buf);
        if v == nfd.token {
            manager.refresh(&mut battery)?;
        } else {
            return Err("Unknown token in file descriptor!".into());
        }
    }
}

extern "C" {
    pub fn notify_register_file_descriptor(
        name: *const c_char,
        notify_fd: *mut c_int,
        flags: c_int,
        out_token: *mut c_int,
    ) -> u32;

    pub fn notify_cancel(token: c_int) -> u32;

    // Added to allow safely setting the fd to non blocking mode
    pub fn notify_suspend(token: ::std::os::raw::c_int) -> u32;
    pub fn notify_resume(token: ::std::os::raw::c_int) -> u32;
}

Asynchronously waiting for events from notify.h and refreshing battery state

The above code produces the same output as the blocking implementation, with the added advantage that we can concurrently do other work while waiting on the file descriptor to be readable. For example, this code can select! on other futures while waiting for the next event.

Conclusion

The notify.h API in macOS allows programs to be signalled when events occur over a file descriptor. It’s easy to safely use notify.h in Rust and integrate with Tokio for asynchronous handling of events. If you ever want to poll for battery state on macOS consider using notify.h to signal when the battery state should be refreshed instead of polling very frequently.


  1. Obviously Rust code↩︎

  2. Core Foundation has an equivalent API in CFNotification but it requires using a CFRunLoop which makes the ‘async’ part of this a little harder.↩︎

  3. This event is published every time the network changes, for example the active WiFi network.↩︎

  4. Searching for strings that start with com.apple.system. in header files in the macOS SDK will show all of the public notify keys.↩︎

  5. There are a few additional functions such as having a block run for every event or have events sent via a match port, but using those from Rust is more difficult.↩︎

  6. I wasn’t able to find definitive documentation if draining the file descriptor is necessary but it seems like the safest thing to do.↩︎