1# encoding: utf-8 2# 3# http://code.arp242.net/battray 4# 5# Copyright © 2008-2017 Martin Tournoij <martin@arp242.net> 6# See below for full copyright 7# 8 9import logging, os, sys 10from gi.repository import Gtk, GLib, GdkPixbuf 11 12from . import platforms, sound 13__all__ = ['platforms', 'sound', 'Battray'] 14 15 16def find_config(configfile=None, datadir=None): 17 xdg_config_home = '{}/battray'.format(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config')) 18 xdg_datadirs = (os.getenv('XDG_DATA_DIRS') or '/usr/local/share:/usr/share').split(':') 19 20 default_config = None 21 22 if datadir is not None: 23 f = '{}/battray/battrayrc.py'.format(datadir) 24 if os.path.exists(f): 25 default_config = f 26 27 for xdg_datadir in xdg_datadirs: 28 if datadir is None: 29 d = '{}/battray'.format(xdg_datadir) 30 if os.path.exists(d): 31 datadir = d 32 33 if default_config is None: 34 f = '{}/battray/battrayrc.py'.format(xdg_datadir) 35 if os.path.exists(f): 36 default_config = f 37 38 # XDG failed; try to load from cwd 39 mydir = os.path.dirname(os.path.realpath(sys.argv[0])) 40 if default_config is None: 41 f = '{}/data/battrayrc.py'.format(mydir) 42 if os.path.exists(f): default_config = f 43 44 if configfile is None: 45 f = '{}/battrayrc.py'.format(xdg_config_home) 46 if os.path.exists(f): 47 configfile = f 48 else: 49 configfile = default_config 50 51 if configfile is None: 52 raise Exception("Can't find config file") 53 54 if datadir is None: 55 d = '{}/data'.format(mydir) 56 if os.path.exists(d): datadir = d 57 58 if datadir is None: 59 raise Exception("Can't find data dir") 60 61 logging.info('Using {}'.format(configfile)) 62 return configfile, datadir, default_config 63 64 65def set_proctitle(title): 66 try: 67 # This is probably the best way to do this, but I don't want to force an 68 # external dependency on this C module... 69 import setproctitle 70 setproctitle.setproctitle(title) 71 except ImportError: 72 import ctypes, ctypes.util 73 74 libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('c')) 75 title_bytes = title.encode(sys.getdefaultencoding(), 'replace') 76 buf = ctypes.create_string_buffer(title_bytes) 77 78 # BSD, maybe also OSX? 79 try: 80 libc.setproctitle(ctypes.create_string_buffer(b"-%s"), buf) 81 return 82 except AttributeError: 83 pass 84 85 # Linux 86 try: 87 libc.prctl(15, buf, 0, 0, 0) 88 return 89 except AttributeError: 90 pass 91 92 93class Battray(object): 94 def __init__(self, interval=None, configfile=None, platform=None, datadir=None): 95 self.configfile, self.datadir, self.default_config = find_config(configfile, datadir) 96 97 if platform: 98 self.platform = getattr(platforms, platform, None) 99 if self.platform is None: 100 logging.error("No such platform: `{}'".format(platform)) 101 sys.exit(1) 102 else: 103 self.platform = platforms.find() 104 self.data = self.played = self.notified = {} 105 106 self.icon = Gtk.StatusIcon() 107 self.icon.set_name('Battray') 108 109 self.menu = Gtk.Menu() 110 refresh = Gtk.MenuItem.new_with_label('Refresh') 111 refresh.connect('activate', self.cb_update) 112 self.menu.append(refresh) 113 114 about = Gtk.MenuItem.new_with_label('About') 115 about.connect('activate', self.cb_about) 116 self.menu.append(about) 117 118 quit = Gtk.MenuItem.new_with_label('Quit') 119 quit.connect('activate', self.cb_destroy) 120 self.menu.append(quit) 121 122 self.icon.connect('activate', self.cb_update) 123 self.icon.connect('popup-menu', self.cb_popup_menu) 124 self.icon.set_visible(True) 125 126 self.update_status() 127 GLib.timeout_add_seconds(interval or 15, self.update_status) 128 129 130 def update_status(self): 131 try: 132 logging.info('Getting status ...') 133 prev_ac = self.data.get('ac') 134 (self.data['bats'], self.data['ac'], self.data['charging'], 135 self.data['percent'], self.data['lifetime']) = self.platform() 136 137 logging.info(self.data) 138 139 if self.data['ac'] and not prev_ac: 140 self.data['switched_to'] = 'ac' 141 elif not self.data['ac'] and prev_ac: 142 self.data['switched_to'] = 'battery' 143 else: 144 self.data['switched_to'] = None 145 146 icon = color = None 147 def set_icon(i): nonlocal icon; icon = i 148 def set_color(c): nonlocal color; color = c 149 150 def play_once(f, k): 151 if self.played.get(k): return 152 self.played[k] = True 153 sound.play('{}/{}'.format(self.datadir, f)) 154 155 def notify_once(m, l, k): 156 if self.notified.get(k): return 157 self.notified[k] = True 158 self.notify(m, l) 159 160 def reset_play_once(k): 161 if self.played.get(k): self.played[k] = False 162 163 def reset_notify_once(k): 164 if self.notified.get(k): self.notified[k] = False 165 166 args = { 167 'bats': self.data['bats'], 168 'ac': self.data['ac'], 169 'charging': self.data['charging'], 170 'percent': self.data['percent'], 171 'lifetime': self.data['lifetime'], 172 'switched_to': self.data['switched_to'], 173 'set_icon': set_icon, 174 'set_color': set_color, 175 'play': lambda f: sound.play('{}/{}'.format(self.datadir, f)), 176 'play_once': play_once, 177 'reset_play_once': reset_play_once, 178 'notify': self.notify, 179 'notify_once': notify_once, 180 'reset_notify_once': reset_notify_once, 181 'run': self.run, 182 } 183 184 def source_default(): 185 exec(open(self.default_config, 'r').read(), args) 186 187 args.update({'source_default': source_default}) 188 exec(open(self.configfile, 'r').read(), args) 189 190 self.set_icon(icon, color) 191 self.set_tooltip() 192 except: 193 print('battray error: {}'.format(sys.exc_info()[1])) 194 if logging.root.level == logging.DEBUG: 195 import traceback 196 traceback.print_tb(sys.exc_info()[2]) 197 return True # Required for GLib.timeout_add_seconds 198 199 200 def run(self, cmd): 201 import subprocess 202 try: 203 o = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True).communicate()[0] 204 logging.info(o) 205 except OSError: 206 logging.warning("something went wrong executing `{}'\n{}\n{}".format( 207 cmd, o, sys.exc_info()[1])) 208 209 210 def notify(self, msg, urgency='normal'): 211 try: 212 # TODO: This is BSD license, so we could just include it 213 import notify2 214 except ImportError: 215 logging.warning("pynotify2 not found; desktop notifications won't work") 216 return 217 218 notify2.init('battray') 219 n = notify2.Notification('Battray', msg, '{}/icon.png'.format(self.datadir)) 220 n.set_urgency({ 221 'low': notify2.URGENCY_LOW, 222 'normal': notify2.URGENCY_NORMAL, 223 'critical': notify2.URGENCY_CRITICAL, 224 }.get(urgency)) 225 n.show() 226 227 228 def set_icon(self, iconf, color=None): 229 if iconf.startswith('/'): 230 iconpath = iconf 231 else: 232 iconpath = '{}/{}'.format(self.datadir, iconf) 233 if '.' not in iconpath: iconpath += '.png' 234 logging.info("setting icon to `{}'".format(iconpath)) 235 236 if color is None: 237 self.icon.set_from_file(iconpath) 238 return 239 240 colors = { 241 'green': 0x1eff19ff, 242 'orange': 0xdeb700ff, 243 'red': 0xff0c00ff, 244 'yellow': 0xeeff2dff, 245 } 246 if color in colors: color = colors[color] 247 248 fill = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 1, 1) 249 fill.fill(color) 250 251 trans = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 1, 1) 252 trans.fill(0) 253 254 icon = GdkPixbuf.Pixbuf.new_from_file(iconpath) 255 256 # TODO: Figure out the longest sequence of pixels to fill, and use that 257 # value to determine how much to fill up; this way it's easier to make 258 # different icons 259 fill_amount = int(round(self.data['percent'] / 4)) 260 261 # TODO: This is neat, but go for a more readable method 262 # Got it from: http://stackoverflow.com/a/1335618/660921 263 for row_num, row in enumerate(zip(*(iter(icon.get_pixels()),) * icon.get_rowstride())): 264 for col_num, pixel in enumerate(zip(*(iter(row),) * 4)): 265 r, g, b, a = pixel 266 if not (r == 255 and g == 0 and b == 255): continue 267 268 if col_num <= fill_amount: 269 fill.copy_area(0, 0, 1, 1, icon, col_num, row_num) 270 else: 271 trans.copy_area(0, 0, 1, 1, icon, col_num, row_num) 272 273 self.icon.set_from_pixbuf(icon) 274 275 276 def set_tooltip(self): 277 bats = self.data['bats'] 278 ac = self.data['ac'] 279 charging = self.data['charging'] 280 percent = self.data['percent'] 281 lifetime = self.data['lifetime'] 282 283 if bats == 0: 284 self.icon.set_tooltip_text('No battery present.') 285 return 286 287 text = [] 288 if ac == None: 289 text.append('Cannot get battery status.\n') 290 elif ac: 291 text.append('Connected to AC power.\n') 292 elif not ac: 293 text.append('Running on battery.\n') 294 295 if percent == -1: 296 text.append('Cannot get battery percentage status.\n') 297 else: 298 text.append('{}% battery power remaining.\n'.format(percent)) 299 300 if lifetime == -1: 301 text.append('Unknown lifetime remaining.\n') 302 elif charging: 303 hours = int(lifetime // 60.0) 304 minutes = int(lifetime % 60) 305 text.append('Approximately {:02}:{:02} remaining.\n'.format(hours, minutes)) 306 elif not ac: 307 hours = int(lifetime // 60.0) 308 minutes = int(lifetime % 60) 309 text.append('Approximately {:02}:{:02} remaining.\n'.format(hours, minutes)) 310 311 if charging == None: 312 pass 313 elif charging == True: 314 text.append('Charging battery.') 315 else: 316 text.append('Not charging battery.') 317 318 self.icon.set_tooltip_text(''.join(text)) 319 320 321 def cb_destroy(self, widget, data=None): 322 self.icon.set_visible(False) 323 Gtk.main_quit() 324 return False 325 326 327 def cb_update(self, widget, data=None): 328 self.update_status() 329 330 331 def cb_popup_menu(self, widget, button, time, data=None): 332 self.menu.show_all() 333 self.menu.popup(None, None, Gtk.StatusIcon.position_menu, self.icon, button, time) 334 335 336 def cb_about(self, widget, data=None): 337 about = Gtk.AboutDialog() 338 about.set_artists(['Martin Tournoij <martin@arp242.net>', 'Keith W. Blackwell']) 339 about.set_authors(['Martin Tournoij <martin@arp242.net>']) 340 about.set_comments('Simple program that displays a tray icon to inform you on your notebooks battery status.') 341 about.set_copyright('Copyright © 2008-2017 Martin Tournoij <martin@arp242.net>') 342 about.set_license_type(Gtk.License.MIT_X11) 343 about.set_logo(GdkPixbuf.Pixbuf.new_from_file('{}/icon.png'.format(self.datadir))) 344 about.set_program_name('Battray') 345 about.set_version('2.3') 346 about.set_website('http://code.arp242.net/battray') 347 348 about.run() 349 about.destroy() 350 351# The MIT License (MIT) 352# 353# Copyright © 2008-2017 Martin Tournoij 354# 355# Permission is hereby granted, free of charge, to any person obtaining a copy 356# of this software and associated documentation files (the "Software"), to 357# deal in the Software without restriction, including without limitation the 358# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 359# sell copies of the Software, and to permit persons to whom the Software is 360# furnished to do so, subject to the following conditions: 361# 362# The above copyright notice and this permission notice shall be included in 363# all copies or substantial portions of the Software. 364# 365# The software is provided "as is", without warranty of any kind, express or 366# implied, including but not limited to the warranties of merchantability, 367# fitness for a particular purpose and noninfringement. In no event shall the 368# authors or copyright holders be liable for any claim, damages or other 369# liability, whether in an action of contract, tort or otherwise, arising 370# from, out of or in connection with the software or the use or other dealings 371# in the software. 372