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