From 4808f43077c9b4bf161e37c6fffc1e9dfbbccf98 Mon Sep 17 00:00:00 2001 From: Anton Khirnov Date: Thu, 5 Jan 2023 16:27:59 +0100 Subject: rc: add a battery level monitor to statusbar Consisting of: - battery object, which is a wrapper around a sysfs dir describing a battery - battery manager, which adds and removes individual batteries as they (dis)appear - e.g. wireless input devices, and maps them to widgets displayed side by side - battery widget, which shows the status of one of the batteries; the widget used is derived from batteryarc [1], but heavily modified [1] https://github.com/streetturtle/awesome-wm-widgets/ --- battery.lua | 114 +++++++++++++++++++++++++++++++ battery_mng.lua | 139 ++++++++++++++++++++++++++++++++++++++ battery_wgt.lua | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ rc.lua | 15 +++-- 4 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 battery.lua create mode 100644 battery_mng.lua create mode 100644 battery_wgt.lua diff --git a/battery.lua b/battery.lua new file mode 100644 index 0000000..abaf86d --- /dev/null +++ b/battery.lua @@ -0,0 +1,114 @@ +local gio = require("lgi").Gio + +local object = require("gears.object") + +local utils = require("utils") + +local Battery = {} +Battery.__index = Battery + +setmetatable(Battery, { + __call = function (cls, ...) return cls.new(...) end, +}) + +Battery.STATUS = { + UNKNOWN = 'Unknown', + DISCHARGING = 'Discharging', + CHARGING = 'Charging', + ['NOT CHARGING'] = 'Not charging', + FULL = 'Full', +} + +function Battery:_readfile(name) + local file = gio.File.new_for_path(self._path .. name) + local contents = file:load_contents(nil) + + -- remove trailing newline + if contents then + contents = string.gsub(contents, "%s$", "") + return string.len(contents) > 0 and contents or nil + end + + return nil +end + +function Battery:_readnum(name) + local val = self:_readfile(name) + return tonumber(val) +end + +function Battery:update() + local status = string.upper(self:_readfile('status') or '') + self.status = self.STATUS[status] + if self.status == nil then + utils.log('Battery/' .. self.name, 'Error updating status: %s', tostring(status)) + return false + end + + self.capacity = self:_readnum('capacity') + self.capacity_level = self:_readfile('capacity_level') + + local energy = self:_readnum('energy_now') + local energy_full = self:_readnum('energy_full') + local power = self:_readnum('power_now') + + -- try to compute remaining seconds to charge/discharge, + -- if we have the information + self.remaining_seconds = nil + + if self.status == self.STATUS.DISCHARGING then + if energy and power then + self.remaining_seconds = 3600 * energy / power + end + elseif self.status == self.STATUS.CHARGING then + if energy and energy_full and power then + self.remaining_seconds = 3600 * (energy_full - energy) / power + end + end + + if power ~= nil then + self.power = power / 1e6 + end + + self:emit_signal('updated') + + return true +end + +function Battery.new(name) + local self = object({class = Battery}) + + self.name = name + self._path = '/sys/class/power_supply/' .. name .. '/' + + -- skip non-battery power supplies, we don't handle them + local type = self:_readfile('type') + if type ~= 'Battery' then + return nil + end + + -- build description string + local desc = nil + + -- first try manufacturer+model + local manufacturer = self:_readfile('manufacturer') + local model = self:_readfile('model_name') + if manufacturer or model then + desc = (manufacturer or '') .. (manufacturer and ' ' or '') .. (model or '') + end + + if not desc then + local technology = self:_readfile('technology') + desc = (technology or '') .. (technology and ' ' or '') .. type + end + + self.desc = desc + + if not self:update() then + return nil + end + + return self +end + +return Battery diff --git a/battery_mng.lua b/battery_mng.lua new file mode 100644 index 0000000..c04c52f --- /dev/null +++ b/battery_mng.lua @@ -0,0 +1,139 @@ +local GFile = require("lgi").Gio.File + +local gears = require("gears") +local spawn = require("awful.spawn") +local wibox = require("wibox") + +local utils = require('utils') + +local BatteryWidget = require('battery_wgt') +local Battery = require('battery') + + +local BatteryManager = {} +BatteryManager.__index = BatteryManager + +setmetatable(BatteryManager, { + __call = function (cls, ...) return cls.new(...) end, +}) + +function BatteryManager:_log(msg, ...) + utils.log('BatteryManager', msg, ...) +end + +function BatteryManager:_warn(msg, ...) + utils.warn('BatteryManager', msg, ...) +end + +function BatteryManager:_battery_add(name) + if self._batteries[name] ~= nil then + -- only warn for usable batteries that are added twice for some reason + if self._batteries[name] ~= false then + self:_warn('Battery %s already exists', name) + end + return + end + + self:_log('Trying power supply: ' .. name) + + local bat = Battery(name) + if not bat then + self:_log('Skipping ' .. name .. ': could not be opened or is not a battery') + -- remember device as unusable + self._batteries[name] = false + return + end + self:_log('Opened battery %s: %s', name, bat.desc) + + local wgt = BatteryWidget(bat) + + self._layout:add(wgt) + + self._batteries[name] = { widget = wgt, battery = bat } +end + +function BatteryManager:_battery_remove(name) + self:_log('Removing battery ' .. name) + + local tbl = self._batteries[name] + if tbl then + self._layout:remove_widgets(tbl.widget) + end + self._batteries[name] = nil +end + +function BatteryManager:_refresh() + for name, item in pairs(self._batteries) do + if item and not item.battery:update() then + self:_log('Could not update battery %s, removing', name) + self:_battery_remove(name) + end + end +end + +function BatteryManager.new() + local self = setmetatable({}, BatteryManager) + + -- the layout containing the individual battery widgets + self._layout = wibox.layout.flex.horizontal() + + -- entries are either + -- - tables { battery, widget } for existing batteries + -- - false for devices that we've seen, but are not valid batteries + self._batteries = {} + + -- try adding all existing batteries + local ps = GFile.new_for_path('/sys/class/power_supply') + local ret = ps:enumerate_children('*', 0) + while true do + local file_info = ret:next_file() + if not file_info then + break + end + + self:_battery_add(file_info:get_name()) + end + + -- monitor udev events + local function monitor_stdout(line) + local action, device = string.match(line, '^KERNEL%[%d+.%d*%]%s+(%a+)%s+([^%s]+)%s+%(power_supply%)$') + if action then + local name = string.match(device, '([^/]+)$') + if name == nil then + self:_warn('Could not extract device name from path: %s', device) + return + end + + if action == 'add' or action == 'change' then + -- battery device not seen before, try adding + if self._batteries[name] == nil then + self:_battery_add(name) + end + + -- device is a valid battery, refresh state + if self._batteries[name] then + self._batteries[name].battery:update() + end + elseif action == 'remove' then + self:_battery_remove(name) + end + end + end + + local function monitor_exit(reason, code) + self:_warn('udevadm monitor exited: %s/%d', reason, code) + end + + spawn.with_line_callback('udevadm monitor --kernel --subsystem-match=power_supply', + { stdout = monitor_stdout, exit = monitor_exit, }) + + self:_refresh() + gears.timer.start_new(5, function () + self:_refresh() + return true + end) + + return self._layout +end + +return BatteryManager diff --git a/battery_wgt.lua b/battery_wgt.lua new file mode 100644 index 0000000..ddf4120 --- /dev/null +++ b/battery_wgt.lua @@ -0,0 +1,204 @@ +-- Battery status widget +-- +-- Based on Battery Arc Widget by Pavel Makhov +-- https://github.com/streetturtle/awesome-wm-widgets/tree/master/batteryarc-widget +-- @copyright 2020 Pavel Makhov +-- The MIT License (MIT) +-- +-- Copyright (c) 2017 +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +local awful = require("awful") +local beautiful = require("beautiful") +-- local menubar_it = require("menubar.icon_theme") +-- local menubar_utils = require("menubar.utils") +local naughty = require("naughty") +local wibox = require("wibox") + +local BatteryWidget = {} +BatteryWidget.__index = BatteryWidget + +setmetatable(BatteryWidget, { + __call = function (cls, ...) return cls.new(...) end, +}) + +local COLORS = { + MAIN_FG = beautiful.fg_color, + MAIN_BG = '#ffffff11', + + + CHARGING = "#43a047", + CHARGE_MEDIUM = "#c0ca33", + CHARGE_LOW = "#e53935", + + NOTIFY_LOW_FG = "#EEE9EF", + NOTIFY_LOW_BG = "#F06060", +} + +function BatteryWidget:_show_battery_warning(charge_percent) + naughty.notify({ + -- icon = warning_msg_icon, + -- icon_size = 100, + title = 'Low battery', + text = string.format('battery at %d%%', charge_percent), + timeout = 25, -- show the warning for a longer time + hover_timeout = 0.5, + bg = COLORS.NOTIFY_LOW_BG, + fg = COLORS.NOTIFY_LOW_FG, + width = 300, + }) +end + +function BatteryWidget:_update() + local capacity = self._bat.capacity + if not capacity then + local cl = self._bat.capacity_level + if cl == 'Full' then capacity = 100 + elseif cl == 'High' then capacity = 75 + elseif cl == 'Normal' then capacity = 50 + elseif cl == 'Low' then capacity = 25 + elseif cl == 'Critical' then capacity = 5 + end + end + + -- hide the widget if the battery is full + if self._bat.status == self._bat.STATUS.FULL then + self._placeholder:set_children({}) + return + end + + -- show the widget if it was previously hidden + if next(self._placeholder.children) == nil then + self._placeholder:set_children({self._arc}) + end + + if self._level_text ~= nil then + --- if battery is fully charged (100) there is not enough place for three digits, so we don't show any text + self._level_text.text = (capacirty and capacity >= 100) and '' or + string.format('%d', capacity // 1) + end + + if self._bat.status == self._bat.STATUS.CHARGING then + self._arc.bg = COLORS.CHARGING + else + self._arc.bg = COLORS.MAIN_BG + end + + if capacity then + self._arc.value = capacity + + if capacity < 15 then + self._arc.colors = { COLORS.CHARGE_LOW } + if self._bat.status == self._bat.STATUS.DISCHARGING and + os.difftime(os.time(), self._last_battery_check) > 300 then + self:_show_battery_warning(capacity) + self._last_battery_check = os.time() + end + elseif capacity < 40 then + self._arc.colors = { COLORS.CHARGE_MEDIUM } + else + self._arc.colors = { COLORS.MAIN_FG } + end + end + + -- build tooltip text + -- start with name + description + local tooltip_text = self._bat.name + if self._bat.desc then + tooltip_text = string.format('%s (%s)', tooltip_text, self._bat.desc) + end + + -- percentage/capacity level + if self._bat.capacity then + tooltip_text = string.format('%s: %d%%', tooltip_text, self._bat.capacity) + elseif self._bat.capacity_level then + tooltip_text = string.format('%s: %s', tooltip_text, self._bat.capacity_level) + end + + -- status + power (if available) + tooltip_text = string.format('%s; %s', tooltip_text, self._bat.status) + if self._bat.power then + tooltip_text = string.format('%s at %.3gW', tooltip_text, self._bat.power) + end + + if self._bat.remaining_seconds then + local sec = self._bat.remaining_seconds + + local h = sec // 3600 + sec = sec - 3600 * h + + local m = sec // 60 + sec = sec - 60 * m + + local s = sec // 1 + + local endstate = self._bat.status == self._bat.STATUS.DISCHARGING and 'empty' or 'full' + tooltip_text = string.format('%s; %02d:%02d:%02d until %s', tooltip_text, h, m, s, endstate) + end + self._tooltip.text = tooltip_text +end + +function BatteryWidget.new(battery, user_args) + local self = setmetatable({}, BatteryWidget) + + local args = user_args or {} + + self._bat = battery + + -- optional textbox showing battery charge percentage + local show_current_level = args.show_current_level or false + if show_current_level then + self._level_text = wibox.widget.textbox() + end + + -- arc indicating battery charge level + self._arc = wibox.widget { + self._level_text, + max_value = 100, + rounded_edge = true, + thickness = args.arc_thickness or 4, + start_angle = 4.71238898, -- 2pi*3/4 + bg = COLORS.MAIN_BG, + paddings = 2, + widget = wibox.container.arcchart + } + + -- placeholder layout used to hide the widget on full battery + self._placeholder = wibox.layout.flex.horizontal() + + -- the actually returned widget, showing the battery only on the primary screen + local ret_widget = awful.widget.only_on_screen(self._placeholder, "primary") + + -- tooltip showing extended battery status + self._tooltip = awful.tooltip({}) + self._tooltip:add_to_object(ret_widget) + + self._last_battery_check = os.time() + + -- wibox.widget.imagebox + -- local it = menubar_it() + -- print(it:find_icon_path('battery-full', 32)) + + battery:connect_signal('updated', function () self:_update() end) + + return ret_widget +end + +return BatteryWidget diff --git a/rc.lua b/rc.lua index 43d5fe3..2bf47ca 100644 --- a/rc.lua +++ b/rc.lua @@ -16,8 +16,9 @@ local utils = require("utils") local workspace = require("workspace") -- local widgets -local pager = require("pager") -local urgent_wgt = require("urgent_wgt") +local BatteryManager = require("battery_mng") +local pager = require("pager") +local urgent_wgt = require("urgent_wgt") -- {{{ Error handling -- Check if awesome encountered an error during startup and fell back to @@ -79,6 +80,8 @@ update_systray_iconsize() -- global, shown only on the primary screen local u_wgt = urgent_wgt.UrgentWidget:new() +local b_mng = BatteryManager() + -- Create a wibox for each screen and add it mywibox = {} mypromptbox = {} @@ -103,12 +106,14 @@ awful.screen.connect_for_each_screen(function(s) awful.button({ }, 3, function () awful.layout.inc(layouts, -1) end), awful.button({ }, 4, function () awful.layout.inc(layouts, 1) end), awful.button({ }, 5, function () awful.layout.inc(layouts, -1) end))) - local layoutbox_container = wibox.container.constraint(layoutbox, 'max', beautiful.xresources.apply_dpi(tray_icon_size)) local layout_utils = wibox.layout.fixed.horizontal() - layout_utils:add(layoutbox_container) + layout_utils:add(layoutbox) + layout_utils:add(b_mng) layout_utils:add(u_wgt.widget) + local utils_bar = wibox.container.constraint(layout_utils, 'max', nil, beautiful.xresources.apply_dpi(tray_icon_size)) + -- Create a tasklist widget local pgr = pager.Pager:new(wsp, s) @@ -117,7 +122,7 @@ awful.screen.connect_for_each_screen(function(s) layout_bottom:add(systray) - layout_bottom:add(layout_utils) + layout_bottom:add(utils_bar) layout_bottom:add(clock_time) layout_bottom:add(clock_date) -- cgit v1.2.3