Compare commits

...

11 Commits

8 changed files with 1030 additions and 102 deletions

58
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Build and Publish Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
paths-ignore:
- "**.md"
- .github/workflows/build-and-release.yml
- .gitignore
- LICENSE
- img/**
jobs:
build-and-publish:
name: Build and Publish Release
permissions:
contents: write
runs-on: windows-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4.1.0
- name: Setup workflow cache
uses: actions/cache@v4.1.0
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: windows-cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rust stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
target: x86_64-pc-windows-msvc
- name: Build
run: cargo build --release --target x86_64-pc-windows-msvc
- name: Upload workflow artifact
uses: actions/upload-artifact@v4.1.0
with:
name: razer-battery-report
path: ./target/x86_64-pc-windows-msvc/release/razer-battery-report.exe
if-no-files-found: error
- name: Publish Release
uses: softprops/action-gh-release@v2.1.0
with:
files: ./target/x86_64-pc-windows-msvc/release/razer-battery-report.exe
draft: true
fail_on_unmatched_files: true

775
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "razer-battery-report" name = "razer-battery-report"
version = "0.2.2" version = "0.3.0"
authors = ["xzeldon <contact@zeldon.ru>"] authors = ["xzeldon <contact@zeldon.ru>"]
edition = "2021" edition = "2021"
description = "Razer Battery Level Tray Indicator" description = "Razer Battery Level Tray Indicator"
@ -11,6 +11,13 @@ lto = "fat"
codegen-units = 1 codegen-units = 1
opt-level = 3 opt-level = 3
# Faster builds, slower executables
[profile.dev]
opt-level = 0
lto = false
incremental = true
codegen-units = 16
[dependencies] [dependencies]
# Communicate with HID devices # Communicate with HID devices
hidapi = "2.6.3" hidapi = "2.6.3"
@ -31,3 +38,6 @@ winapi = { version = "0.3.9", features = ["winuser", "wincon", "consoleapi"] }
# Efficient synchronization primitives (e.g. Mutex, RwLock and etc.) # Efficient synchronization primitives (e.g. Mutex, RwLock and etc.)
parking_lot = "0.12" parking_lot = "0.12"
# Desktop notifications
notify-rust = "4.11.4"

View File

@ -10,15 +10,17 @@
Show your wireless Razer devices battery levels in your system tray. Show your wireless Razer devices battery levels in your system tray.
> This is a work in progress and currently support only **Razer DeathAdder V3 Pro**. > This is a work in progress and currently support only **Razer DeathAdder V3 Pro** and **Razer DeathAdder V3 HyperSpeed**.
> Currently, this works only on **Windows**, should work on **Linux** if you _add udev rule to get access to usb devices_ (see [here](https://github.com/libusb/hidapi/blob/master/udev/69-hid.rules)) and remove/`cfg(windows)` some platform-specific code. But I haven't tested yet. > Currently, this works only on **Windows**.
## Usage ## Usage
### Downloading a Prebuilt Binary ### Installation
> _Todo_ 1. Download `razer-battery-report.exe` from [latest release](https://github.com/xzeldon/razer-battery-report/releases/latest)
2. Run `razer-battery-report.exe`
3. If you want a start menu shortcut you can make one yourself! Simply right-click `razer-battery-report.exe` and select "Pin to Start". This will automatically create a shortcut in %appdata%\Microsoft\Windows\Start Menu\Programs.
### Building from Source ### Building from Source
@ -41,10 +43,10 @@ To build, you must have [Rust](https://www.rust-lang.org/) and
- [x] Tray Applet - [x] Tray Applet
- [ ] Force update devices button in tray menu - [ ] Force update devices button in tray menu
- [ ] Colored tray icons for different battery levels - [x] Colored tray icons for different battery levels
- [x] Show log window button in tray menu - [x] Show log window button in tray menu
- [ ] Further reduce CPU usage by using Event Loop Proxy events (more info [here](https://github.com/tauri-apps/tray-icon/issues/83#issuecomment-1697773065)) - [x] Further reduce CPU usage by using Event Loop Proxy events (more info [here](https://github.com/tauri-apps/tray-icon/issues/83#issuecomment-1697773065))
- [ ] Prebuilt Binary - [x] Prebuilt Binary
- [ ] Command Line Arguments for update frequency - [ ] Command Line Arguments for update frequency
- [ ] Support for other Razer Devices (I only have DeathAdder V3 Pro, so I won't be able to test it with other devices) - [ ] Support for other Razer Devices (I only have DeathAdder V3 Pro, so I won't be able to test it with other devices)

View File

@ -28,7 +28,9 @@ impl DeviceInfo {
pub const fn transaction_id(&self) -> u8 { pub const fn transaction_id(&self) -> u8 {
match self.pid { match self.pid {
pid if pid == RAZER_DEATHADDER_V3_PRO_WIRED.pid pid if pid == RAZER_DEATHADDER_V3_PRO_WIRED.pid
|| pid == RAZER_DEATHADDER_V3_PRO_WIRELESS.pid => || pid == RAZER_DEATHADDER_V3_PRO_WIRELESS.pid
|| pid == RAZER_DEATHADDER_V3_HYPERSPEED_WIRED.pid
|| pid == RAZER_DEATHADDER_V3_HYPERSPEED_WIRELESS.pid =>
{ {
0x1F 0x1F
} }
@ -38,11 +40,18 @@ impl DeviceInfo {
} }
pub const RAZER_DEATHADDER_V3_PRO_WIRED: DeviceInfo = pub const RAZER_DEATHADDER_V3_PRO_WIRED: DeviceInfo =
DeviceInfo::new("Razer DeathAdder V3 Pro", 0x00B6, 0, 1, 2); DeviceInfo::new("Razer DeathAdder V3 Pro (Wired)", 0x00B6, 0, 1, 2);
pub const RAZER_DEATHADDER_V3_PRO_WIRELESS: DeviceInfo = pub const RAZER_DEATHADDER_V3_PRO_WIRELESS: DeviceInfo =
DeviceInfo::new("Razer DeathAdder V3 Pro", 0x00B7, 0, 1, 2); DeviceInfo::new("Razer DeathAdder V3 Pro (Wireless)", 0x00B7, 0, 1, 2);
pub const RAZER_DEVICE_LIST: [DeviceInfo; 2] = [ pub const RAZER_DEATHADDER_V3_HYPERSPEED_WIRED: DeviceInfo =
DeviceInfo::new("Razer DeathAdder V3 HyperSpeed (Wired)", 0x00C4, 0, 1, 2);
pub const RAZER_DEATHADDER_V3_HYPERSPEED_WIRELESS: DeviceInfo =
DeviceInfo::new("Razer DeathAdder V3 HyperSpeed (Wireless)", 0x00C5, 0, 1, 2);
pub const RAZER_DEVICE_LIST: [DeviceInfo; 4] = [
RAZER_DEATHADDER_V3_PRO_WIRED, RAZER_DEATHADDER_V3_PRO_WIRED,
RAZER_DEATHADDER_V3_PRO_WIRELESS, RAZER_DEATHADDER_V3_PRO_WIRELESS,
RAZER_DEATHADDER_V3_HYPERSPEED_WIRED,
RAZER_DEATHADDER_V3_HYPERSPEED_WIRELESS,
]; ];

View File

@ -7,6 +7,7 @@ mod console;
mod controller; mod controller;
mod devices; mod devices;
mod manager; mod manager;
mod notify;
mod tray; mod tray;
fn main() { fn main() {

53
src/notify.rs Normal file
View File

@ -0,0 +1,53 @@
use notify_rust::Notification;
pub struct Notify {
app_name: String,
}
impl Notify {
pub fn new() -> Self {
#[cfg(target_os = "windows")]
Self {
app_name: String::from("Razer Battery Report"),
}
}
pub fn battery_low(
&self,
device_name: &str,
battery_level: i32,
) -> Result<(), Box<dyn std::error::Error>> {
Notification::new()
.summary(&self.app_name)
.body(&format!(
"{}: Battery low ({}%)",
device_name, battery_level
))
.show()?;
Ok(())
}
pub fn battery_full(&self, device_name: &str) -> Result<(), Box<dyn std::error::Error>> {
Notification::new()
.summary(&self.app_name)
.body(&format!("{}: Battery fully charged", device_name))
.show()?;
Ok(())
}
pub fn device_connected(&self, device_name: &str) -> Result<(), Box<dyn std::error::Error>> {
Notification::new()
.summary(&self.app_name)
.body(&format!("{}: Connected", device_name))
.show()?;
Ok(())
}
pub fn device_disconnecred(&self, device_name: &str) -> Result<(), Box<dyn std::error::Error>> {
Notification::new()
.summary(&self.app_name)
.body(&format!("{}: Disconnected", device_name))
.show()?;
Ok(())
}
}

View File

@ -1,23 +1,25 @@
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
rc::Rc, sync::Arc,
sync::{mpsc, Arc},
thread, thread,
time::{Duration, Instant}, time::Duration,
}; };
use crate::{console::DebugConsole, manager::DeviceManager}; use crate::{console::DebugConsole, manager::DeviceManager, notify::Notify};
use log::{error, info, trace}; use log::{error, info, trace};
use parking_lot::Mutex; use parking_lot::Mutex;
use tao::event_loop::EventLoopBuilder; use tao::event_loop::{EventLoopBuilder, EventLoopProxy};
use tray_icon::{ use tray_icon::{
menu::{Menu, MenuEvent, MenuItem}, menu::{IsMenuItem, Menu, MenuEvent, MenuItem},
TrayIcon, TrayIconBuilder, TrayIcon, TrayIconBuilder,
}; };
const BATTERY_UPDATE_INTERVAL: Duration = Duration::from_secs(60); const BATTERY_UPDATE_INTERVAL: Duration = Duration::from_secs(300); // 5 min
const DEVICE_FETCH_INTERVAL: Duration = Duration::from_secs(5); const DEVICE_FETCH_INTERVAL: Duration = Duration::from_secs(5);
const BATTERY_CRITICAL_LEVEL: i32 = 5;
const BATTERY_LOW_LEVEL: i32 = 15;
#[derive(Debug)] #[derive(Debug)]
pub struct MemoryDevice { pub struct MemoryDevice {
pub name: String, pub name: String,
@ -41,7 +43,7 @@ impl MemoryDevice {
} }
pub struct TrayInner { pub struct TrayInner {
tray_icon: Rc<Mutex<Option<TrayIcon>>>, tray_icon: Arc<Mutex<Option<TrayIcon>>>,
menu_items: Arc<Mutex<Vec<MenuItem>>>, menu_items: Arc<Mutex<Vec<MenuItem>>>,
debug_console: Arc<DebugConsole>, debug_console: Arc<DebugConsole>,
} }
@ -49,7 +51,7 @@ pub struct TrayInner {
impl TrayInner { impl TrayInner {
fn new(debug_console: Arc<DebugConsole>) -> Self { fn new(debug_console: Arc<DebugConsole>) -> Self {
Self { Self {
tray_icon: Rc::new(Mutex::new(None)), tray_icon: Arc::new(Mutex::new(None)),
menu_items: Arc::new(Mutex::new(Vec::new())), menu_items: Arc::new(Mutex::new(Vec::new())),
debug_console, debug_console,
} }
@ -65,20 +67,25 @@ impl TrayInner {
menu_items.push(show_console_item); menu_items.push(show_console_item);
menu_items.push(quit_item); menu_items.push(quit_item);
let item_refs: Vec<&dyn IsMenuItem> = menu_items
.iter()
.map(|item| item as &dyn IsMenuItem)
.collect();
tray_menu tray_menu
.append_items(&[&menu_items[0], &menu_items[1]]) .append_items(&item_refs)
.unwrap(); .expect("Failed to append menu items");
tray_menu tray_menu
} }
fn build_tray( fn build_tray(
tray_icon: &Rc<Mutex<Option<TrayIcon>>>, tray_icon: &Arc<Mutex<Option<TrayIcon>>>,
tray_menu: &Menu, tray_menu: &Menu,
icon: tray_icon::Icon, icon: tray_icon::Icon,
) { ) {
let tray_builder = TrayIconBuilder::new() let tray_builder = TrayIconBuilder::new()
.with_menu(Box::new(tray_menu.clone())) .with_menu(Box::new(tray_menu.clone()))
.with_tooltip("Service is running") .with_tooltip("Search for devices")
.with_icon(icon) .with_icon(icon)
.build(); .build();
@ -93,6 +100,13 @@ pub struct TrayApp {
device_manager: Arc<Mutex<DeviceManager>>, device_manager: Arc<Mutex<DeviceManager>>,
devices: Arc<Mutex<HashMap<u32, MemoryDevice>>>, devices: Arc<Mutex<HashMap<u32, MemoryDevice>>>,
tray_inner: TrayInner, tray_inner: TrayInner,
notify: Arc<Notify>,
}
#[derive(Debug)]
enum TrayEvent {
DeviceUpdate(Vec<u32>),
MenuEvent(MenuEvent),
} }
impl TrayApp { impl TrayApp {
@ -101,20 +115,21 @@ impl TrayApp {
device_manager: Arc::new(Mutex::new(DeviceManager::new())), device_manager: Arc::new(Mutex::new(DeviceManager::new())),
devices: Arc::new(Mutex::new(HashMap::new())), devices: Arc::new(Mutex::new(HashMap::new())),
tray_inner: TrayInner::new(Arc::new(debug_console)), tray_inner: TrayInner::new(Arc::new(debug_console)),
notify: Arc::new(Notify::new()),
} }
} }
pub fn run(&self) { pub fn run(&self) {
let icon = Self::create_icon(); let icon = Self::create_icon();
let event_loop = EventLoopBuilder::new().build(); let event_loop = EventLoopBuilder::with_user_event().build();
let tray_menu = self.tray_inner.create_menu(); let tray_menu = self.tray_inner.create_menu();
let (sender, receiver) = mpsc::channel(); let proxy = event_loop.create_proxy();
self.spawn_device_fetch_thread(sender.clone()); self.spawn_device_fetch_thread(proxy.clone());
self.spawn_battery_check_thread(sender); self.spawn_battery_check_thread(proxy.clone());
self.run_event_loop(event_loop, icon, tray_menu, receiver); self.run_event_loop(event_loop, icon, tray_menu, proxy);
} }
fn create_icon() -> tray_icon::Icon { fn create_icon() -> tray_icon::Icon {
@ -128,113 +143,140 @@ impl TrayApp {
tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon") tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
} }
fn spawn_device_fetch_thread(&self, tx: mpsc::Sender<Vec<u32>>) { fn spawn_device_fetch_thread(&self, proxy: EventLoopProxy<TrayEvent>) {
let devices = Arc::clone(&self.devices); let devices = Arc::clone(&self.devices);
let device_manager = Arc::clone(&self.device_manager); let device_manager = Arc::clone(&self.device_manager);
let notify = Arc::clone(&self.notify);
thread::spawn(move || loop { thread::spawn(move || {
let (removed_devices, connected_devices) = { let mut last_devices = HashSet::new();
let mut manager = device_manager.lock(); loop {
manager.fetch_devices() let (removed_devices, connected_devices) = {
}; let mut manager = device_manager.lock();
manager.fetch_devices()
};
{
let mut devices = devices.lock(); let mut devices = devices.lock();
for id in removed_devices { for id in removed_devices {
if let Some(device) = devices.remove(&id) { if let Some(device) = devices.remove(&id) {
info!("Device removed: {}", device.name); info!("Device removed: {}", device.name);
let _ = notify.device_disconnecred(&device.name);
} }
} }
for &id in &connected_devices { for &id in &connected_devices {
if let std::collections::hash_map::Entry::Vacant(e) = devices.entry(id) { if !devices.contains_key(&id) {
if let Some(name) = device_manager.lock().get_device_name(id) { if let Some(name) = device_manager.lock().get_device_name(id) {
e.insert(MemoryDevice::new(name.clone(), id)); devices.insert(id, MemoryDevice::new(name.clone(), id));
info!("New device: {}", name); info!("New device: {}", name);
let _ = notify.device_connected(&name);
} else { } else {
error!("Failed to get device name for id: {}", id); error!("Failed to get device name for id: {}", id);
} }
} }
} }
}
if !connected_devices.is_empty() { let current_devices: HashSet<_> = connected_devices.iter().cloned().collect();
tx.send(connected_devices).unwrap(); if current_devices != last_devices {
} let _ = proxy.send_event(TrayEvent::DeviceUpdate(connected_devices));
last_devices = current_devices;
}
thread::sleep(DEVICE_FETCH_INTERVAL); thread::sleep(DEVICE_FETCH_INTERVAL);
}
}); });
} }
fn spawn_battery_check_thread(&self, tx: mpsc::Sender<Vec<u32>>) { fn spawn_battery_check_thread(&self, proxy: EventLoopProxy<TrayEvent>) {
let devices = Arc::clone(&self.devices); let devices = Arc::clone(&self.devices);
thread::spawn(move || loop { thread::spawn(move || loop {
let device_ids: Vec<u32> = devices.lock().keys().cloned().collect(); let device_ids: Vec<u32> = devices.lock().keys().cloned().collect();
tx.send(device_ids).unwrap(); let _ = proxy.send_event(TrayEvent::DeviceUpdate(device_ids));
thread::sleep(BATTERY_UPDATE_INTERVAL); thread::sleep(BATTERY_UPDATE_INTERVAL);
}); });
} }
fn run_event_loop( fn run_event_loop(
&self, &self,
event_loop: tao::event_loop::EventLoop<()>, event_loop: tao::event_loop::EventLoop<TrayEvent>,
icon: tray_icon::Icon, icon: tray_icon::Icon,
tray_menu: Menu, tray_menu: Menu,
rx: mpsc::Receiver<Vec<u32>>, proxy: EventLoopProxy<TrayEvent>,
) { ) {
let devices = Arc::clone(&self.devices); let devices = Arc::clone(&self.devices);
let device_manager = Arc::clone(&self.device_manager); let device_manager = Arc::clone(&self.device_manager);
let tray_icon = Rc::clone(&self.tray_inner.tray_icon); let tray_icon = Arc::clone(&self.tray_inner.tray_icon);
let debug_console = Arc::clone(&self.tray_inner.debug_console); let debug_console = Arc::clone(&self.tray_inner.debug_console);
let menu_items = Arc::clone(&self.tray_inner.menu_items); let menu_items = Arc::clone(&self.tray_inner.menu_items);
let notify = Arc::clone(&self.notify);
let menu_channel = MenuEvent::receiver(); let menu_channel = MenuEvent::receiver();
event_loop.run(move |event, _, control_flow| { event_loop.run(move |event, _, control_flow| {
*control_flow = tao::event_loop::ControlFlow::WaitUntil( *control_flow = tao::event_loop::ControlFlow::Wait;
Instant::now() + Duration::from_millis(100),
);
if let Ok(device_ids) = rx.try_recv() { match event {
Self::update(&devices, &device_manager, &device_ids, &tray_icon); tao::event::Event::NewEvents(tao::event::StartCause::Init) => {
} TrayInner::build_tray(&tray_icon, &tray_menu, icon.clone());
}
tao::event::Event::UserEvent(TrayEvent::DeviceUpdate(device_ids)) => {
Self::update(&devices, &device_manager, &device_ids, &tray_icon, &notify);
}
tao::event::Event::UserEvent(TrayEvent::MenuEvent(event)) => {
let menu_items = menu_items.lock();
if let tao::event::Event::NewEvents(tao::event::StartCause::Init) = event { if event.id == menu_items[0].id() {
// We create the icon once the event loop is actually running debug_console.toggle_visibility();
// to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 let visible = debug_console.is_visible();
TrayInner::build_tray(&tray_icon, &tray_menu, icon.clone()); menu_items[0].set_text(if visible {
"Hide Log Window"
} else {
"Show Log Window"
});
trace!("{} log window", if visible { "showing" } else { "hiding" });
}
if event.id == menu_items[1].id() {
*control_flow = tao::event_loop::ControlFlow::Exit;
}
}
_ => (),
} }
if let Ok(event) = menu_channel.try_recv() { if let Ok(event) = menu_channel.try_recv() {
let menu_items = menu_items.lock(); let _ = proxy.send_event(TrayEvent::MenuEvent(event));
let show_console_item = &menu_items[0];
let quit_item = &menu_items[1];
if event.id == show_console_item.id() {
debug_console.toggle_visibility();
let visible = debug_console.is_visible();
show_console_item.set_text(if visible {
"Hide Log Window"
} else {
"Show Log Window"
});
trace!("{} log window", if visible { "showing" } else { "hiding" });
}
if event.id == quit_item.id() {
*control_flow = tao::event_loop::ControlFlow::Exit;
}
} }
}); });
} }
fn get_battery_icon(battery_level: i32, is_charging: bool) -> tray_icon::Icon {
let icon = match (battery_level, is_charging) {
(lvl, _) if lvl <= BATTERY_CRITICAL_LEVEL && !is_charging => {
include_bytes!("../assets/mouse_red.png").to_vec()
}
(lvl, _) if lvl <= BATTERY_LOW_LEVEL && !is_charging => {
include_bytes!("../assets/mouse_yellow.png").to_vec()
}
_ => include_bytes!("../assets/mouse_white.png").to_vec(),
};
let image = image::load_from_memory(&icon)
.expect("Failed to open icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
fn update( fn update(
devices: &Arc<Mutex<HashMap<u32, MemoryDevice>>>, devices: &Arc<Mutex<HashMap<u32, MemoryDevice>>>,
manager: &Arc<Mutex<DeviceManager>>, manager: &Arc<Mutex<DeviceManager>>,
device_ids: &[u32], device_ids: &[u32],
tray_icon: &Rc<Mutex<Option<TrayIcon>>>, tray_icon: &Arc<Mutex<Option<TrayIcon>>>,
notify: &Arc<Notify>,
) { ) {
let mut devices = devices.lock(); let mut devices = devices.lock();
let manager = manager.lock(); let manager = manager.lock();
@ -252,7 +294,18 @@ impl TrayApp {
device.battery_level = battery_level; device.battery_level = battery_level;
device.is_charging = is_charging; device.is_charging = is_charging;
Self::check_notify(device); Self::check_notify(device, notify);
if device.old_battery_level != battery_level
|| device.is_charging != is_charging
{
let new_icon = Self::get_battery_icon(battery_level, is_charging);
if let Some(tray_icon) = tray_icon.lock().as_mut() {
tray_icon
.set_icon(Some(new_icon))
.expect("Failed to update tray icon");
}
}
if let Some(tray_icon) = tray_icon.lock().as_mut() { if let Some(tray_icon) = tray_icon.lock().as_mut() {
let _ = tray_icon let _ = tray_icon
@ -263,16 +316,18 @@ impl TrayApp {
} }
} }
fn check_notify(device: &MemoryDevice) { fn check_notify(device: &MemoryDevice, notify: &Notify) {
if device.battery_level == -1 { if device.battery_level == -1 {
return; return;
} }
if !device.is_charging if !device.is_charging
&& (device.battery_level <= 5 && (device.battery_level <= BATTERY_CRITICAL_LEVEL
|| (device.old_battery_level > 15 && device.battery_level <= 15)) || (device.old_battery_level > BATTERY_LOW_LEVEL
&& device.battery_level <= BATTERY_LOW_LEVEL))
{ {
info!("{}: Battery low ({}%)", device.name, device.battery_level); info!("{}: Battery low ({}%)", device.name, device.battery_level);
let _ = notify.battery_low(&device.name, device.battery_level);
} else if device.old_battery_level <= 99 } else if device.old_battery_level <= 99
&& device.battery_level == 100 && device.battery_level == 100
&& device.is_charging && device.is_charging
@ -281,6 +336,7 @@ impl TrayApp {
"{}: Battery fully charged ({}%)", "{}: Battery fully charged ({}%)",
device.name, device.battery_level device.name, device.battery_level
); );
let _ = notify.battery_full(&device.name);
} }
} }
} }