2018-09-05 09:07:37 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
from updaters import *
|
|
|
|
from display import *
|
|
|
|
import pulsectl
|
|
|
|
import psutil
|
|
|
|
import subprocess
|
|
|
|
import socket
|
|
|
|
import ipaddress
|
|
|
|
import logging
|
|
|
|
import coloredlogs
|
|
|
|
import json
|
2018-09-06 07:38:22 +02:00
|
|
|
import notmuch
|
|
|
|
import mpd
|
|
|
|
import random
|
2018-10-18 21:14:11 +02:00
|
|
|
import taskw
|
|
|
|
import math
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
coloredlogs.install(level='DEBUG', fmt='%(levelname)s %(message)s')
|
|
|
|
log = logging.getLogger()
|
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
# TODO Generator class (for I3WorkspacesProvider, NetworkProvider and later
|
|
|
|
# PulseaudioProvider and MpdProvider)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def humanSize(num):
|
|
|
|
"""
|
|
|
|
Returns a string of width 3+3
|
|
|
|
"""
|
|
|
|
for unit in ('B ','KiB','MiB','GiB','TiB','PiB','EiB','ZiB'):
|
|
|
|
if abs(num) < 1000:
|
|
|
|
if num >= 10:
|
|
|
|
return "{:3d}{}".format(int(num), unit)
|
|
|
|
else:
|
|
|
|
return "{:.1f}{}".format(num, unit)
|
|
|
|
num /= 1024.0
|
|
|
|
return "{:d}YiB".format(num)
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def randomColor(seed=0):
|
|
|
|
random.seed(seed)
|
|
|
|
return '#{:02x}{:02x}{:02x}'.format(*[random.randint(0, 255) for _ in range(3)])
|
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 12:17:03 +02:00
|
|
|
class TimeProvider(StatefulSection, PeriodicUpdater):
|
|
|
|
|
|
|
|
FORMATS = ["%H:%M",
|
2018-11-24 13:45:14 +01:00
|
|
|
"%m-%d %H:%M:%S",
|
|
|
|
"%a %y-%m-%d %H:%M:%S"]
|
2018-09-06 12:17:03 +02:00
|
|
|
NUMBER_STATES = len(FORMATS)
|
2018-10-06 10:27:36 +02:00
|
|
|
DEFAULT_STATE = 1
|
2018-09-06 12:17:03 +02:00
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
def fetcher(self):
|
|
|
|
now = datetime.datetime.now()
|
2018-09-06 12:17:03 +02:00
|
|
|
return now.strftime(self.FORMATS[self.state])
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def __init__(self, theme=None):
|
2018-09-05 09:07:37 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
2018-09-06 12:17:03 +02:00
|
|
|
StatefulSection.__init__(self, theme)
|
|
|
|
self.changeInterval(1) # TODO OPTI When state < 1
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
class AlertLevel(enum.Enum):
|
|
|
|
NORMAL = 0
|
|
|
|
WARNING = 1
|
|
|
|
DANGER = 2
|
|
|
|
|
|
|
|
class AlertingSection(StatefulSection):
|
|
|
|
# TODO EASE Correct settings for themes
|
|
|
|
THEMES = {AlertLevel.NORMAL: 2,
|
|
|
|
AlertLevel.WARNING: 3,
|
|
|
|
AlertLevel.DANGER: 1}
|
|
|
|
PERSISTENT = True
|
|
|
|
|
|
|
|
def getLevel(self, quantity):
|
|
|
|
if quantity > self.dangerThresold:
|
|
|
|
return AlertLevel.DANGER
|
|
|
|
elif quantity > self.warningThresold:
|
|
|
|
return AlertLevel.WARNING
|
|
|
|
else:
|
|
|
|
return AlertLevel.NORMAL
|
|
|
|
|
|
|
|
def updateLevel(self, quantity):
|
|
|
|
self.level = self.getLevel(quantity)
|
|
|
|
self.updateTheme(self.THEMES[self.level])
|
|
|
|
if self.level == AlertLevel.NORMAL:
|
|
|
|
return
|
|
|
|
# TODO Temporary update state
|
|
|
|
|
|
|
|
def __init__(self, theme):
|
|
|
|
StatefulSection.__init__(self, theme)
|
|
|
|
self.dangerThresold = 0.90
|
|
|
|
self.warningThresold = 0.75
|
|
|
|
|
|
|
|
|
|
|
|
class CpuProvider(AlertingSection, PeriodicUpdater):
|
|
|
|
NUMBER_STATES = 3
|
|
|
|
ICON = ''
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def fetcher(self):
|
|
|
|
percent = psutil.cpu_percent(percpu=False)
|
2018-09-06 17:00:46 +02:00
|
|
|
self.updateLevel(percent/100)
|
|
|
|
if self.state >= 2:
|
|
|
|
percents = psutil.cpu_percent(percpu=True)
|
|
|
|
return ''.join([Section.ramp(p/100) for p in percents])
|
|
|
|
elif self.state >= 1:
|
|
|
|
return Section.ramp(percent/100)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def __init__(self, theme=None):
|
2018-09-06 17:00:46 +02:00
|
|
|
AlertingSection.__init__(self, theme)
|
2018-09-06 07:38:22 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
|
|
|
self.changeInterval(1)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
class RamProvider(AlertingSection, PeriodicUpdater):
|
2018-09-06 07:38:22 +02:00
|
|
|
"""
|
|
|
|
Shows free RAM
|
|
|
|
"""
|
2018-09-06 22:07:32 +02:00
|
|
|
NUMBER_STATES = 4
|
|
|
|
ICON = ''
|
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
def fetcher(self):
|
2018-09-06 07:38:22 +02:00
|
|
|
mem = psutil.virtual_memory()
|
2018-10-06 10:27:36 +02:00
|
|
|
freePerc = mem.percent/100
|
2018-09-06 22:07:32 +02:00
|
|
|
self.updateLevel(freePerc)
|
|
|
|
|
|
|
|
if self.state < 1:
|
|
|
|
return None
|
|
|
|
|
2019-10-17 12:44:30 +02:00
|
|
|
text = Text(Section.ramp(freePerc))
|
2018-09-06 22:07:32 +02:00
|
|
|
if self.state >= 2:
|
2019-10-17 12:44:30 +02:00
|
|
|
freeStr = humanSize(mem.total - mem.available)
|
2018-09-06 22:07:32 +02:00
|
|
|
text.append(freeStr)
|
|
|
|
if self.state >= 3:
|
|
|
|
totalStr = humanSize(mem.total)
|
|
|
|
text.append('/', totalStr)
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
AlertingSection.__init__(self, theme)
|
2018-09-05 09:07:37 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
2018-09-06 07:38:22 +02:00
|
|
|
self.changeInterval(1)
|
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
class TemperatureProvider(AlertingSection, PeriodicUpdater):
|
|
|
|
NUMBER_STATES = 2
|
2018-09-06 07:38:22 +02:00
|
|
|
RAMP = ""
|
|
|
|
|
|
|
|
def fetcher(self):
|
|
|
|
allTemp = psutil.sensors_temperatures()
|
|
|
|
if 'coretemp' not in allTemp:
|
|
|
|
# TODO Opti Remove interval
|
|
|
|
return ''
|
|
|
|
temp = allTemp['coretemp'][0]
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
self.warningThresold = temp.high
|
|
|
|
self.dangerThresold = temp.critical
|
|
|
|
self.updateLevel(temp.current)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
self.icon = Section.ramp(temp.current/temp.high, self.RAMP)
|
|
|
|
if self.state >= 1:
|
|
|
|
return '{:.0f}°C'.format(temp.current)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def __init__(self, theme=None):
|
|
|
|
AlertingSection.__init__(self, theme)
|
2018-09-06 07:38:22 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
|
|
|
self.changeInterval(5)
|
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
class BatteryProvider(AlertingSection, PeriodicUpdater):
|
2018-10-06 10:27:36 +02:00
|
|
|
# TODO Support ACPID for events
|
|
|
|
NUMBER_STATES = 3
|
2018-09-06 07:38:22 +02:00
|
|
|
RAMP = ""
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def fetcher(self):
|
|
|
|
bat = psutil.sensors_battery()
|
|
|
|
if not bat:
|
2018-09-06 22:07:32 +02:00
|
|
|
self.icon = None
|
|
|
|
return None
|
|
|
|
|
|
|
|
self.icon = ("" if bat.power_plugged else "") + \
|
|
|
|
Section.ramp(bat.percent/100, self.RAMP)
|
|
|
|
|
|
|
|
self.updateLevel(1-bat.percent/100)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
if self.state < 1:
|
|
|
|
return
|
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
t = Text('{:.0f}%'.format(bat.percent))
|
|
|
|
|
|
|
|
if self.state < 2:
|
|
|
|
return t
|
|
|
|
|
|
|
|
h = int(bat.secsleft / 3600)
|
|
|
|
m = int((bat.secsleft - h * 3600) / 60)
|
|
|
|
t.append(" ({:d}:{:02d})".format(h, m))
|
|
|
|
return t
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
AlertingSection.__init__(self, theme)
|
2018-09-06 07:38:22 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
2018-09-05 09:07:37 +02:00
|
|
|
self.changeInterval(5)
|
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
class PulseaudioProvider(StatefulSection, ThreadedUpdater):
|
|
|
|
NUMBER_STATES = 3
|
|
|
|
DEFAULT_STATE = 1
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def __init__(self, theme=None):
|
2018-09-05 09:07:37 +02:00
|
|
|
ThreadedUpdater.__init__(self)
|
2018-10-06 10:27:36 +02:00
|
|
|
StatefulSection.__init__(self, theme)
|
2018-09-05 09:07:37 +02:00
|
|
|
self.pulseEvents = pulsectl.Pulse('event-handler')
|
|
|
|
|
|
|
|
self.pulseEvents.event_mask_set(pulsectl.PulseEventMaskEnum.sink)
|
|
|
|
self.pulseEvents.event_callback_set(self.handleEvent)
|
|
|
|
self.start()
|
|
|
|
self.refreshData()
|
|
|
|
|
|
|
|
|
|
|
|
def fetcher(self):
|
|
|
|
sinks = []
|
|
|
|
with pulsectl.Pulse('list-sinks') as pulse:
|
|
|
|
for sink in pulse.sink_list():
|
|
|
|
if sink.port_active.name == "analog-output-headphones":
|
|
|
|
icon = ""
|
|
|
|
elif sink.port_active.name == "analog-output-speaker":
|
2018-10-06 10:27:36 +02:00
|
|
|
icon = "" if sink.mute else ""
|
2018-10-08 23:52:53 +02:00
|
|
|
elif sink.port_active.name == "headset-output":
|
|
|
|
icon = ''
|
2018-09-05 09:07:37 +02:00
|
|
|
else:
|
|
|
|
icon = "?"
|
2018-10-06 10:27:36 +02:00
|
|
|
vol = pulse.volume_get_all_chans(sink)
|
|
|
|
fg = (sink.mute and '#333333') or (vol > 1 and '#FF0000') or None
|
|
|
|
|
|
|
|
t = Text(icon, fg=fg)
|
|
|
|
sinks.append(t)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
if self.state < 1:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if self.state < 2:
|
|
|
|
if not sink.mute:
|
|
|
|
ramp = " "
|
|
|
|
while vol >= 0:
|
|
|
|
ramp += self.ramp(vol if vol < 1 else 1)
|
|
|
|
vol -= 1
|
|
|
|
t.append(ramp)
|
|
|
|
else:
|
|
|
|
t.append(" {:2.0f}%".format(vol*100))
|
|
|
|
|
|
|
|
return Text(*sinks)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def loop(self):
|
|
|
|
self.pulseEvents.event_listen()
|
|
|
|
|
|
|
|
def handleEvent(self, ev):
|
|
|
|
self.refreshData()
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-06 12:17:03 +02:00
|
|
|
class NetworkProviderSection(StatefulSection, Updater):
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
NUMBER_STATES = 5
|
2018-10-06 10:27:36 +02:00
|
|
|
DEFAULT_STATE = 1
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
def actType(self):
|
|
|
|
self.ssid = None
|
2018-09-06 07:38:22 +02:00
|
|
|
if self.iface.startswith('eth') or self.iface.startswith('enp'):
|
|
|
|
if 'u' in self.iface:
|
2018-09-06 17:00:46 +02:00
|
|
|
self.icon = ''
|
2018-09-06 07:38:22 +02:00
|
|
|
else:
|
2018-09-06 17:00:46 +02:00
|
|
|
self.icon = ''
|
2018-10-08 23:52:53 +02:00
|
|
|
elif self.iface.startswith('wlan') or self.iface.startswith('wl'):
|
2018-09-06 17:00:46 +02:00
|
|
|
self.icon = ''
|
|
|
|
if self.showSsid:
|
2018-09-06 12:17:03 +02:00
|
|
|
cmd = ["iwgetid", self.iface, "--raw"]
|
|
|
|
p = subprocess.run(cmd, stdout=subprocess.PIPE)
|
2018-09-06 17:00:46 +02:00
|
|
|
self.ssid = p.stdout.strip().decode()
|
2018-09-06 07:38:22 +02:00
|
|
|
elif self.iface.startswith('tun') or self.iface.startswith('tap'):
|
2018-09-06 17:00:46 +02:00
|
|
|
self.icon = ''
|
2018-09-06 07:38:22 +02:00
|
|
|
else:
|
2018-09-06 17:00:46 +02:00
|
|
|
self.icon = '?'
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def getAddresses(self):
|
2018-09-05 09:07:37 +02:00
|
|
|
ipv4 = None
|
|
|
|
ipv6 = None
|
|
|
|
for address in self.parent.addrs[self.iface]:
|
|
|
|
if address.family == socket.AF_INET:
|
|
|
|
ipv4 = address
|
|
|
|
elif address.family == socket.AF_INET6:
|
|
|
|
ipv6 = address
|
2018-09-06 07:38:22 +02:00
|
|
|
return ipv4, ipv6
|
|
|
|
|
|
|
|
def fetcher(self):
|
2018-10-06 10:27:36 +02:00
|
|
|
self.icon = None
|
|
|
|
self.persistent = False
|
2018-09-06 07:38:22 +02:00
|
|
|
if self.iface not in self.parent.stats or \
|
|
|
|
not self.parent.stats[self.iface].isup or \
|
|
|
|
self.iface.startswith('lo'):
|
2018-09-06 17:00:46 +02:00
|
|
|
return None
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
# Get addresses
|
|
|
|
ipv4, ipv6 = self.getAddresses()
|
|
|
|
if ipv4 is None and ipv6 is None:
|
2018-09-06 17:00:46 +02:00
|
|
|
return None
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
text = []
|
2018-09-06 22:07:32 +02:00
|
|
|
self.persistent = True
|
2018-09-06 17:00:46 +02:00
|
|
|
self.actType()
|
|
|
|
|
|
|
|
if self.showSsid and self.ssid:
|
|
|
|
text.append(self.ssid)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
if self.showAddress:
|
|
|
|
if ipv4:
|
|
|
|
netStrFull = '{}/{}'.format(ipv4.address, ipv4.netmask)
|
|
|
|
addr = ipaddress.IPv4Network(netStrFull, strict=False)
|
2018-09-06 17:00:46 +02:00
|
|
|
addrStr = '{}/{}'.format(ipv4.address, addr.prefixlen)
|
|
|
|
text.append(addrStr)
|
2018-09-05 09:07:37 +02:00
|
|
|
# TODO IPV6
|
|
|
|
# if ipv6:
|
|
|
|
# text += ' ' + ipv6.address
|
|
|
|
|
|
|
|
if self.showSpeed:
|
|
|
|
recvDiff = self.parent.IO[self.iface].bytes_recv \
|
|
|
|
- self.parent.prevIO[self.iface].bytes_recv
|
|
|
|
sentDiff = self.parent.IO[self.iface].bytes_sent \
|
|
|
|
- self.parent.prevIO[self.iface].bytes_sent
|
|
|
|
recvDiff /= self.parent.dt
|
|
|
|
sentDiff /= self.parent.dt
|
2018-09-06 17:00:46 +02:00
|
|
|
text.append('↓{}↑{}'.format(humanSize(recvDiff),
|
|
|
|
humanSize(sentDiff)))
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
if self.showTransfer:
|
2018-09-06 17:00:46 +02:00
|
|
|
text.append('⇓{}⇑{}'.format(
|
2018-09-05 09:07:37 +02:00
|
|
|
humanSize(self.parent.IO[self.iface].bytes_recv),
|
2018-09-06 17:00:46 +02:00
|
|
|
humanSize(self.parent.IO[self.iface].bytes_sent)))
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
return ' '.join(text)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 12:17:03 +02:00
|
|
|
def onChangeState(self, state):
|
2018-09-06 22:07:32 +02:00
|
|
|
self.showSsid = state >= 1
|
|
|
|
self.showAddress = state >= 2
|
|
|
|
self.showSpeed = state >= 3
|
|
|
|
self.showTransfer = state >= 4
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def __init__(self, iface, parent):
|
2018-09-06 07:38:22 +02:00
|
|
|
Updater.__init__(self)
|
2018-09-06 12:17:03 +02:00
|
|
|
StatefulSection.__init__(self, theme=parent.theme)
|
2018-09-05 09:07:37 +02:00
|
|
|
self.iface = iface
|
|
|
|
self.parent = parent
|
|
|
|
|
|
|
|
|
|
|
|
class NetworkProvider(Section, PeriodicUpdater):
|
|
|
|
def fetchData(self):
|
|
|
|
self.prev = self.last
|
|
|
|
self.prevIO = self.IO
|
|
|
|
|
|
|
|
self.stats = psutil.net_if_stats()
|
|
|
|
self.addrs = psutil.net_if_addrs()
|
|
|
|
self.IO = psutil.net_io_counters(pernic=True)
|
|
|
|
self.ifaces = self.stats.keys()
|
|
|
|
|
|
|
|
self.last = time.perf_counter()
|
|
|
|
self.dt = self.last - self.prev
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def fetcher(self):
|
2018-09-05 09:07:37 +02:00
|
|
|
self.fetchData()
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
# Add missing sections
|
2018-09-05 09:07:37 +02:00
|
|
|
lastSection = self
|
|
|
|
for iface in sorted(list(self.ifaces)):
|
|
|
|
if iface not in self.sections.keys():
|
|
|
|
section = NetworkProviderSection(iface, self)
|
|
|
|
lastSection.appendAfter(section)
|
|
|
|
self.sections[iface] = section
|
|
|
|
else:
|
|
|
|
section = self.sections[iface]
|
|
|
|
lastSection = section
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
# Refresh section text
|
|
|
|
for section in self.sections.values():
|
|
|
|
section.refreshData()
|
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
return None
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
def addParent(self, parent):
|
|
|
|
self.parents.add(parent)
|
|
|
|
self.refreshData()
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def __init__(self, theme=None):
|
2018-09-05 09:07:37 +02:00
|
|
|
PeriodicUpdater.__init__(self)
|
2018-09-06 07:38:22 +02:00
|
|
|
Section.__init__(self, theme)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
self.sections = dict()
|
|
|
|
self.last = 0
|
|
|
|
self.IO = dict()
|
|
|
|
self.fetchData()
|
2018-09-06 07:38:22 +02:00
|
|
|
self.changeInterval(5)
|
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
class RfkillProvider(Section, PeriodicUpdater):
|
|
|
|
# TODO FEAT rfkill doesn't seem to indicate that the hardware switch is
|
|
|
|
# toggled
|
|
|
|
PATH = '/sys/class/rfkill'
|
|
|
|
|
|
|
|
def fetcher(self):
|
|
|
|
t = Text()
|
|
|
|
for device in os.listdir(self.PATH):
|
|
|
|
with open(os.path.join(self.PATH, device, 'soft'), 'rb') as f:
|
|
|
|
softBlocked = f.read().strip() != b'0'
|
|
|
|
with open(os.path.join(self.PATH, device, 'hard'), 'rb') as f:
|
|
|
|
hardBlocked = f.read().strip() != b'0'
|
|
|
|
|
|
|
|
if not hardBlocked and not softBlocked:
|
|
|
|
continue
|
|
|
|
|
|
|
|
with open(os.path.join(self.PATH, device, 'type'), 'rb') as f:
|
|
|
|
typ = f.read().strip()
|
|
|
|
|
|
|
|
fg = (hardBlocked and '#CCCCCC') or (softBlocked and '#FF0000')
|
|
|
|
if typ == b'wlan':
|
|
|
|
icon = ''
|
|
|
|
elif typ == b'bluetooth':
|
|
|
|
icon = ''
|
|
|
|
else:
|
|
|
|
icon = '?'
|
|
|
|
|
|
|
|
t.append(Text(icon, fg=fg))
|
|
|
|
return t
|
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
PeriodicUpdater.__init__(self)
|
|
|
|
Section.__init__(self, theme)
|
|
|
|
self.changeInterval(5)
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
class SshAgentProvider(PeriodicUpdater):
|
|
|
|
def fetcher(self):
|
|
|
|
cmd = ["ssh-add", "-l"]
|
|
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
|
|
if proc.returncode != 0:
|
2018-09-06 17:00:46 +02:00
|
|
|
return None
|
|
|
|
text = Text()
|
2018-09-06 07:38:22 +02:00
|
|
|
for line in proc.stdout.split(b'\n'):
|
|
|
|
if not len(line):
|
|
|
|
continue
|
|
|
|
fingerprint = line.split()[1]
|
2018-09-06 17:00:46 +02:00
|
|
|
text.append(Text('', fg=randomColor(seed=fingerprint)))
|
2018-09-06 07:38:22 +02:00
|
|
|
return text
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
PeriodicUpdater.__init__(self)
|
|
|
|
self.changeInterval(5)
|
|
|
|
|
|
|
|
class GpgAgentProvider(PeriodicUpdater):
|
|
|
|
def fetcher(self):
|
|
|
|
cmd = ["gpg-connect-agent", "keyinfo --list", "/bye"]
|
|
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
|
|
# proc = subprocess.run(cmd)
|
|
|
|
if proc.returncode != 0:
|
2018-09-06 17:00:46 +02:00
|
|
|
return None
|
|
|
|
text = Text()
|
2018-09-06 07:38:22 +02:00
|
|
|
for line in proc.stdout.split(b'\n'):
|
|
|
|
if not len(line) or line == b'OK':
|
|
|
|
continue
|
|
|
|
spli = line.split()
|
|
|
|
if spli[6] != b'1':
|
|
|
|
continue
|
|
|
|
keygrip = spli[2]
|
2018-09-06 17:00:46 +02:00
|
|
|
text.append(Text('', fg=randomColor(seed=keygrip)))
|
2018-09-06 07:38:22 +02:00
|
|
|
return text
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
PeriodicUpdater.__init__(self)
|
|
|
|
self.changeInterval(5)
|
|
|
|
|
|
|
|
class KeystoreProvider(Section, MergedUpdater):
|
2018-10-06 10:27:36 +02:00
|
|
|
# TODO OPTI+FEAT Use ColorCountsSection and not MergedUpdater, this is useless
|
2018-09-06 17:00:46 +02:00
|
|
|
ICON = ''
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def __init__(self, theme=None):
|
2018-09-06 17:00:46 +02:00
|
|
|
MergedUpdater.__init__(self, SshAgentProvider(), GpgAgentProvider())
|
2018-09-06 07:38:22 +02:00
|
|
|
Section.__init__(self, theme)
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
class NotmuchUnreadProvider(ColorCountsSection, InotifyUpdater):
|
2018-09-06 17:00:46 +02:00
|
|
|
COLORABLE_ICON = ''
|
2018-09-06 12:17:03 +02:00
|
|
|
|
|
|
|
def subfetcher(self):
|
2018-09-06 07:38:22 +02:00
|
|
|
db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY, path=self.dir)
|
2018-09-06 12:17:03 +02:00
|
|
|
counts = []
|
2018-09-06 07:38:22 +02:00
|
|
|
for account in self.accounts:
|
|
|
|
queryStr = 'folder:/{}/ and tag:unread'.format(account)
|
|
|
|
query = notmuch.Query(db, queryStr)
|
|
|
|
nbMsgs = query.count_messages()
|
2018-10-14 16:59:49 +02:00
|
|
|
if account == 'frogeye':
|
|
|
|
global q
|
|
|
|
q = query
|
2018-09-06 07:38:22 +02:00
|
|
|
if nbMsgs < 1:
|
|
|
|
continue
|
2018-09-06 12:17:03 +02:00
|
|
|
counts.append((nbMsgs, self.colors[account]))
|
2018-10-14 16:59:49 +02:00
|
|
|
# db.close()
|
2018-09-06 12:17:03 +02:00
|
|
|
return counts
|
2018-09-06 07:38:22 +02:00
|
|
|
|
|
|
|
def __init__(self, dir='~/.mail/', theme=None):
|
|
|
|
PeriodicUpdater.__init__(self)
|
2018-09-06 12:17:03 +02:00
|
|
|
ColorCountsSection.__init__(self, theme)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
|
|
|
self.dir = os.path.realpath(os.path.expanduser(dir))
|
|
|
|
assert os.path.isdir(self.dir)
|
|
|
|
|
|
|
|
# Fetching account list
|
|
|
|
self.accounts = sorted([a for a in os.listdir(self.dir)
|
|
|
|
if not a.startswith('.')])
|
|
|
|
# Fetching colors
|
|
|
|
self.colors = dict()
|
|
|
|
for account in self.accounts:
|
|
|
|
filename = os.path.join(self.dir, account, 'color')
|
|
|
|
with open(filename, 'r') as f:
|
|
|
|
color = f.read().strip()
|
|
|
|
self.colors[account] = color
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
self.addPath(os.path.join(self.dir, '.notmuch', 'xapian'))
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
|
2018-10-18 21:14:11 +02:00
|
|
|
class TaskWarriorProvider(StatefulSection, InotifyUpdater):
|
|
|
|
ICON = ''
|
|
|
|
NUMBER_STATES = 2
|
|
|
|
DEFAULT_STATE = 1
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
InotifyUpdater.__init__(self)
|
|
|
|
StatefulSection.__init__(self, theme=theme)
|
|
|
|
self.taskw = taskw.TaskWarrior()
|
|
|
|
self.addPath(os.path.expanduser(self.taskw.config['data']['location']))
|
|
|
|
|
|
|
|
def fetcher(self):
|
|
|
|
maxi = -math.inf
|
|
|
|
total = 0
|
2019-01-06 14:05:05 +01:00
|
|
|
for task in self.taskw.load_tasks('pending')['pending']:
|
2018-10-18 21:14:11 +02:00
|
|
|
urgency = task['urgency']
|
|
|
|
if urgency > maxi:
|
|
|
|
maxi = urgency
|
|
|
|
if urgency > 0:
|
|
|
|
total += urgency
|
|
|
|
t = Text()
|
2018-10-25 22:35:02 +02:00
|
|
|
t.append(f"{maxi:.1f}")
|
2018-10-18 21:14:11 +02:00
|
|
|
|
|
|
|
if self.showTotal:
|
2018-10-25 22:35:02 +02:00
|
|
|
t.append(f" | {total:.1f}")
|
2018-10-18 21:14:11 +02:00
|
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
def onChangeState(self, state):
|
|
|
|
self.showTotal = state >= 1
|
|
|
|
|
|
|
|
|
2018-09-06 12:17:03 +02:00
|
|
|
class TodoProvider(ColorCountsSection, InotifyUpdater):
|
2018-09-05 09:07:37 +02:00
|
|
|
# TODO OPT/UX Maybe we could get more data from the todoman python module
|
|
|
|
# TODO OPT Specific callback for specific directory
|
2018-09-06 17:00:46 +02:00
|
|
|
COLORABLE_ICON = ''
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def updateCalendarList(self):
|
|
|
|
calendars = sorted(os.listdir(self.dir))
|
|
|
|
for calendar in calendars:
|
|
|
|
# If the calendar wasn't in the list
|
|
|
|
if calendar not in self.calendars:
|
|
|
|
self.addPath(os.path.join(self.dir, calendar), refresh=False)
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
# Fetching name
|
|
|
|
path = os.path.join(self.dir, calendar, 'displayname')
|
|
|
|
with open(path, 'r') as f:
|
|
|
|
self.names[calendar] = f.read().strip()
|
|
|
|
|
|
|
|
# Fetching color
|
|
|
|
path = os.path.join(self.dir, calendar, 'color')
|
|
|
|
with open(path, 'r') as f:
|
|
|
|
self.colors[calendar] = f.read().strip()
|
2018-09-05 09:07:37 +02:00
|
|
|
self.calendars = calendars
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
def __init__(self, dir, theme=None):
|
2018-09-05 09:07:37 +02:00
|
|
|
"""
|
|
|
|
:parm str dir: [main]path value in todoman.conf
|
|
|
|
"""
|
|
|
|
InotifyUpdater.__init__(self)
|
2018-09-06 12:17:03 +02:00
|
|
|
ColorCountsSection.__init__(self, theme=theme)
|
2018-09-05 09:07:37 +02:00
|
|
|
self.dir = os.path.realpath(os.path.expanduser(dir))
|
|
|
|
assert os.path.isdir(self.dir)
|
|
|
|
|
|
|
|
self.calendars = []
|
2018-09-06 22:07:32 +02:00
|
|
|
self.colors = dict()
|
|
|
|
self.names = dict()
|
|
|
|
self.updateCalendarList()
|
|
|
|
self.refreshData()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def countUndone(self, calendar):
|
2018-09-06 22:07:32 +02:00
|
|
|
cmd = ["todo", "--porcelain", "list"]
|
|
|
|
if calendar:
|
|
|
|
cmd.append(self.names[calendar])
|
2018-09-05 09:07:37 +02:00
|
|
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE)
|
|
|
|
data = json.loads(proc.stdout)
|
|
|
|
return len(data)
|
|
|
|
|
2018-09-06 12:17:03 +02:00
|
|
|
def subfetcher(self):
|
|
|
|
counts = []
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
# TODO This an ugly optimisation that cuts on features, but todoman
|
|
|
|
# calls are very expensive so we keep that in the meanwhile
|
|
|
|
if self.state < 2:
|
|
|
|
c = self.countUndone(None)
|
|
|
|
if c > 0:
|
|
|
|
counts.append((c, '#00000'))
|
|
|
|
counts.append((0, '#FFFFF'))
|
|
|
|
return counts
|
|
|
|
# Optimisation ends here
|
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
for calendar in self.calendars:
|
|
|
|
c = self.countUndone(calendar)
|
2018-09-06 12:17:03 +02:00
|
|
|
if c <= 0:
|
|
|
|
continue
|
2018-09-06 22:07:32 +02:00
|
|
|
counts.append((c, self.colors[calendar]))
|
2018-09-06 12:17:03 +02:00
|
|
|
return counts
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
class I3WindowTitleProvider(Section, I3Updater):
|
|
|
|
# TODO FEAT To make this available from start, we need to find the
|
|
|
|
# `focused=True` element following the `focus` array
|
|
|
|
# TODO Feat Make this output dependant if wanted
|
|
|
|
def on_window(self, i3, e):
|
|
|
|
self.updateText(e.container.name)
|
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
I3Updater.__init__(self)
|
|
|
|
Section.__init__(self, theme=theme)
|
|
|
|
self.on("window", self.on_window)
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
class I3WorkspacesProviderSection(Section):
|
|
|
|
def selectTheme(self):
|
|
|
|
if self.urgent:
|
|
|
|
return self.parent.themeUrgent
|
|
|
|
elif self.focused:
|
|
|
|
return self.parent.themeFocus
|
|
|
|
else:
|
|
|
|
return self.parent.themeNormal
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
# TODO On mode change the state (shown / hidden) gets overriden so every
|
|
|
|
# tab is shown
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def show(self):
|
|
|
|
self.updateTheme(self.selectTheme())
|
|
|
|
self.updateText(self.fullName if self.focused else self.shortName)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def changeState(self, focused, urgent):
|
|
|
|
self.focused = focused
|
|
|
|
self.urgent = urgent
|
|
|
|
self.show()
|
|
|
|
|
|
|
|
def setName(self, name):
|
|
|
|
self.shortName = name
|
|
|
|
self.fullName = self.parent.customNames[name] \
|
|
|
|
if name in self.parent.customNames else name
|
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
def switchTo(self):
|
|
|
|
self.parent.i3.command('workspace {}'.format(self.shortName))
|
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def __init__(self, name, parent):
|
|
|
|
Section.__init__(self)
|
|
|
|
self.parent = parent
|
|
|
|
self.setName(name)
|
2018-10-06 10:27:36 +02:00
|
|
|
self.setDecorators(clickLeft=self.switchTo)
|
2018-11-24 13:45:14 +01:00
|
|
|
self.tempText = None
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
def empty(self):
|
|
|
|
self.updateTheme(self.parent.themeNormal)
|
|
|
|
self.updateText(None)
|
|
|
|
|
2018-11-24 13:45:14 +01:00
|
|
|
def tempShow(self):
|
|
|
|
self.updateText(self.tempText)
|
|
|
|
|
|
|
|
def tempEmpty(self):
|
|
|
|
self.tempText = self.dstText[1]
|
|
|
|
self.updateText(None)
|
|
|
|
|
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
class I3WorkspacesProvider(Section, I3Updater):
|
2018-10-06 10:27:36 +02:00
|
|
|
# TODO FEAT Multi-screen
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def initialPopulation(self, parent):
|
|
|
|
"""
|
|
|
|
Called on init
|
|
|
|
Can't reuse addWorkspace since i3.get_workspaces() gives dict and not
|
|
|
|
ConObjects
|
|
|
|
"""
|
|
|
|
workspaces = self.i3.get_workspaces()
|
|
|
|
lastSection = self.modeSection
|
|
|
|
for workspace in workspaces:
|
|
|
|
# if parent.display != workspace["display"]:
|
|
|
|
# continue
|
2018-09-06 22:07:32 +02:00
|
|
|
|
2019-10-17 12:44:30 +02:00
|
|
|
section = I3WorkspacesProviderSection(workspace.name, self)
|
|
|
|
section.focused = workspace.focused
|
|
|
|
section.urgent = workspace.urgent
|
2018-09-06 22:07:32 +02:00
|
|
|
section.show()
|
2018-09-05 09:07:37 +02:00
|
|
|
parent.addSectionAfter(lastSection, section)
|
2019-10-17 12:44:30 +02:00
|
|
|
self.sections[workspace.num] = section
|
2018-09-06 22:07:32 +02:00
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
lastSection = section
|
|
|
|
|
|
|
|
def on_workspace_init(self, i3, e):
|
|
|
|
workspace = e.current
|
|
|
|
i = workspace.num
|
|
|
|
if i in self.sections:
|
|
|
|
section = self.sections[i]
|
|
|
|
else:
|
2018-09-06 22:07:32 +02:00
|
|
|
# Find the section just before
|
2018-09-05 09:07:37 +02:00
|
|
|
while i not in self.sections.keys() and i > 0:
|
|
|
|
i -= 1
|
|
|
|
prevSection = self.sections[i] if i != 0 else self.modeSection
|
2018-09-06 22:07:32 +02:00
|
|
|
|
|
|
|
section = I3WorkspacesProviderSection(workspace.name, self)
|
2018-09-05 09:07:37 +02:00
|
|
|
prevSection.appendAfter(section)
|
2018-09-06 22:07:32 +02:00
|
|
|
self.sections[workspace.num] = section
|
|
|
|
section.focused = workspace.focused
|
|
|
|
section.urgent = workspace.urgent
|
|
|
|
section.show()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def on_workspace_empty(self, i3, e):
|
2018-09-06 22:07:32 +02:00
|
|
|
self.sections[e.current.num].empty()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def on_workspace_focus(self, i3, e):
|
2018-09-06 22:07:32 +02:00
|
|
|
self.sections[e.old.num].focused = False
|
|
|
|
self.sections[e.old.num].show()
|
|
|
|
self.sections[e.current.num].focused = True
|
|
|
|
self.sections[e.current.num].show()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def on_workspace_urgent(self, i3, e):
|
2018-09-06 22:07:32 +02:00
|
|
|
self.sections[e.current.num].urgent = e.current.urgent
|
|
|
|
self.sections[e.current.num].show()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def on_workspace_rename(self, i3, e):
|
2018-09-06 22:07:32 +02:00
|
|
|
self.sections[e.current.num].setName(e.name)
|
|
|
|
self.sections[e.current.num].show()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def on_mode(self, i3, e):
|
|
|
|
if e.change == 'default':
|
2018-09-06 22:07:32 +02:00
|
|
|
self.modeSection.updateText(None)
|
2018-09-05 09:07:37 +02:00
|
|
|
for section in self.sections.values():
|
2018-11-24 13:45:14 +01:00
|
|
|
section.tempShow()
|
2018-09-05 09:07:37 +02:00
|
|
|
else:
|
|
|
|
self.modeSection.updateText(e.change)
|
|
|
|
for section in self.sections.values():
|
2018-11-24 13:45:14 +01:00
|
|
|
section.tempEmpty()
|
2018-09-05 09:07:37 +02:00
|
|
|
|
2018-09-06 22:07:32 +02:00
|
|
|
def __init__(self, theme=0, themeFocus=3, themeUrgent=1, themeMode=2, customNames=dict()):
|
2018-09-06 07:38:22 +02:00
|
|
|
I3Updater.__init__(self)
|
2018-09-05 09:07:37 +02:00
|
|
|
Section.__init__(self)
|
2018-09-06 22:07:32 +02:00
|
|
|
self.themeNormal = theme
|
2018-09-06 07:38:22 +02:00
|
|
|
self.themeFocus = themeFocus
|
|
|
|
self.themeUrgent = themeUrgent
|
2018-09-06 22:07:32 +02:00
|
|
|
self.customNames = customNames
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
self.sections = dict()
|
2018-09-06 07:38:22 +02:00
|
|
|
self.on("workspace::init", self.on_workspace_init)
|
|
|
|
self.on("workspace::focus", self.on_workspace_focus)
|
|
|
|
self.on("workspace::empty", self.on_workspace_empty)
|
|
|
|
self.on("workspace::urgent", self.on_workspace_urgent)
|
|
|
|
self.on("workspace::rename", self.on_workspace_rename)
|
2018-09-05 09:07:37 +02:00
|
|
|
# TODO Un-handled/tested: reload, rename, restored, move
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
self.on("mode", self.on_mode)
|
|
|
|
self.modeSection = Section(theme=themeMode)
|
2018-09-05 09:07:37 +02:00
|
|
|
|
|
|
|
def addParent(self, parent):
|
|
|
|
self.parents.add(parent)
|
|
|
|
parent.addSection(self.modeSection)
|
|
|
|
self.initialPopulation(parent)
|
|
|
|
|
2018-09-06 07:38:22 +02:00
|
|
|
class MpdProvider(Section, ThreadedUpdater):
|
2018-09-06 17:00:46 +02:00
|
|
|
# TODO FEAT More informations and controls
|
2018-09-06 07:38:22 +02:00
|
|
|
|
|
|
|
MAX_LENGTH = 50
|
|
|
|
|
|
|
|
def connect(self):
|
|
|
|
self.mpd.connect('localhost', 6600)
|
|
|
|
|
|
|
|
def __init__(self, theme=None):
|
|
|
|
ThreadedUpdater.__init__(self)
|
|
|
|
Section.__init__(self, theme)
|
|
|
|
|
|
|
|
self.mpd = mpd.MPDClient()
|
|
|
|
self.connect()
|
|
|
|
self.refreshData()
|
|
|
|
self.start()
|
|
|
|
|
|
|
|
def fetcher(self):
|
2018-10-06 10:27:36 +02:00
|
|
|
stat = self.mpd.status()
|
|
|
|
if not len(stat) or stat["state"] == "stop":
|
|
|
|
return None
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-10-06 10:27:36 +02:00
|
|
|
cur = self.mpd.currentsong()
|
2018-09-06 07:38:22 +02:00
|
|
|
if not len(cur):
|
2018-10-06 10:27:36 +02:00
|
|
|
return None
|
2018-09-06 07:38:22 +02:00
|
|
|
|
|
|
|
infos = []
|
|
|
|
|
|
|
|
def tryAdd(field):
|
|
|
|
if field in cur:
|
|
|
|
infos.append(cur[field])
|
|
|
|
|
|
|
|
tryAdd("title")
|
|
|
|
tryAdd("album")
|
|
|
|
tryAdd("artist")
|
|
|
|
|
|
|
|
infosStr = " - ".join(infos)
|
|
|
|
if len(infosStr) > MpdProvider.MAX_LENGTH:
|
|
|
|
infosStr = infosStr[:MpdProvider.MAX_LENGTH-1] + '…'
|
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
return " {}".format(infosStr)
|
2018-09-06 07:38:22 +02:00
|
|
|
|
2018-09-05 09:07:37 +02:00
|
|
|
def loop(self):
|
2018-09-06 07:38:22 +02:00
|
|
|
try:
|
|
|
|
self.mpd.idle('player')
|
|
|
|
self.refreshData()
|
|
|
|
except mpd.base.ConnectionError as e:
|
|
|
|
log.warn(e, exc_info=True)
|
|
|
|
self.connect()
|
|
|
|
except BaseException as e:
|
|
|
|
log.error(e, exc_info=True)
|
|
|
|
|
2018-09-06 17:00:46 +02:00
|
|
|
|