Skip to content

Commit d9e2e28

Browse files
committed
feat: misc improvements, added hourly command
1 parent 139afac commit d9e2e28

File tree

1 file changed

+90
-16
lines changed

1 file changed

+90
-16
lines changed

aw_notify/main.py

+90-16
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
and send notifications to the user on predefined conditions.
44
"""
55

6+
import logging
7+
import threading
68
from datetime import datetime, timedelta, timezone
79
from time import sleep
810

@@ -11,6 +13,8 @@
1113
import click
1214
from desktop_notifier import DesktopNotifier
1315

16+
logger = logging.getLogger(__name__)
17+
1418
# TODO: Get categories from aw-webui export (in the future from server key-val store)
1519
# TODO: Add thresholds for total time today (incl percentage of productive time)
1620

@@ -39,10 +43,10 @@
3943

4044
time_offset = timedelta(hours=4)
4145

46+
aw = aw_client.ActivityWatchClient("aw-notify", testing=False)
4247

43-
def get_time(category: str) -> timedelta:
44-
aw = aw_client.ActivityWatchClient("aw-notify", testing=False)
4548

49+
def get_time(category: str) -> timedelta:
4650
now = datetime.now(timezone.utc)
4751
timeperiods = [
4852
(
@@ -51,10 +55,11 @@ def get_time(category: str) -> timedelta:
5155
)
5256
]
5357

58+
hostname = aw.get_info().get("hostname", "unknown")
5459
canonicalQuery = aw_client.queries.canonicalEvents(
5560
aw_client.queries.DesktopQueryParams(
56-
bid_window="aw-watcher-window_erb-m2.localdomain",
57-
bid_afk="aw-watcher-afk_erb-m2.localdomain",
61+
bid_window=f"aw-watcher-window_{hostname}",
62+
bid_afk=f"aw-watcher-afk_{hostname}",
5863
classes=CATEGORIES,
5964
filter_classes=[[category]] if category else [],
6065
)
@@ -89,19 +94,19 @@ def to_hms(duration: timedelta) -> str:
8994
notifier: DesktopNotifier = None
9095

9196

92-
def notify(msg: str):
97+
def notify(title: str, msg: str):
9398
# send a notification to the user
9499

95100
global notifier
96101
if notifier is None:
97102
notifier = DesktopNotifier(
98-
app_name="ActivityWatch notify",
103+
app_name="ActivityWatch",
99104
# icon="file:///path/to/icon.png",
100105
notification_limit=10,
101106
)
102107

103108
print(msg)
104-
notifier.send_sync(title="AW", message=msg)
109+
notifier.send_sync(title=title, message=msg)
105110

106111

107112
td15min = timedelta(minutes=15)
@@ -153,21 +158,24 @@ def update(self):
153158
time_to_threshold = self.time_to_next_threshold
154159
# print("Update?")
155160
if now > (self.last_check + time_to_threshold):
156-
print(f"Updating {self.category}")
161+
logger.debug(f"Updating {self.category}")
157162
# print(f"Time to threshold: {time_to_threshold}")
158163
self.last_check = now
159164
self.time_spent = get_time(self.category)
160165
else:
161166
pass
162-
# print("Not updating, too soon")
167+
# logger.debug("Not updating, too soon")
163168

164169
def check(self):
165170
"""Check if thresholds have been reached"""
166171
for thres in sorted(self.thresholds_untriggered, reverse=True):
167172
if thres <= self.time_spent:
168173
# threshold reached
169174
self.max_triggered = thres
170-
notify(f"[Alert]: {self.category or 'All'} for {to_hms(thres)}")
175+
notify(
176+
"Time spent",
177+
f"{self.category or 'All'}: {to_hms(self.time_spent)}",
178+
)
171179
break
172180

173181
def status(self) -> str:
@@ -183,14 +191,17 @@ def test_category_alert():
183191

184192

185193
@click.group()
186-
def main():
187-
pass
194+
@click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode.")
195+
def main(verbose: bool):
196+
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
197+
logging.getLogger("urllib3").setLevel(logging.WARNING)
188198

189199

190200
@main.command()
191201
def start():
192202
"""Start the notification service."""
193203
checkin()
204+
hourly()
194205
threshold_alerts()
195206

196207

@@ -208,24 +219,87 @@ def threshold_alerts():
208219
for alert in alerts:
209220
alert.update()
210221
alert.check()
211-
print(alert.status())
222+
status = alert.status()
223+
if status != getattr(alert, "last_status", None):
224+
print(f"New status: {status}")
225+
alert.last_status = status
212226

213227
sleep(10)
214228

215229

216230
@main.command()
231+
def _checkin():
232+
"""Send a summary notification."""
233+
checkin()
234+
235+
217236
def checkin():
218237
"""
219238
Sends a summary notification of the day.
220239
Meant to be sent at a particular time, like at the end of a working day (e.g. 5pm).
221240
"""
222241
# TODO: load categories from data
223-
top_categories = ["", "Work", "Twitter"]
242+
top_categories = [""] + [k[0] for k, _ in CATEGORIES]
224243
time_spent = [get_time(c) for c in top_categories]
225244
msg = f"Time spent today: {sum(time_spent, timedelta())}\n"
226245
msg += "Categories:\n"
227-
msg += "\n".join(f" - {c}: {t}" for c, t in zip(top_categories, time_spent))
228-
notify(msg)
246+
msg += "\n".join(
247+
f" - {c if c else 'All'}: {t}"
248+
for c, t in sorted(
249+
zip(top_categories, time_spent), key=lambda x: x[1], reverse=True
250+
)
251+
)
252+
notify("Checkin", msg)
253+
254+
255+
def get_active_status() -> bool:
256+
"""
257+
Get active status by polling latest event in aw-watcher-afk bucket.
258+
Returns True if user is active/not-afk, False if not.
259+
On error, like out-of-date event, returns None.
260+
"""
261+
262+
hostname = aw.get_info().get("hostname", "unknown")
263+
events = aw.get_events(f"aw-watcher-afk_{hostname}", limit=1)
264+
print(events)
265+
if not events:
266+
return None
267+
event = events[0]
268+
if event.timestamp < datetime.now(timezone.utc) - timedelta(minutes=5):
269+
# event is too old
270+
logger.warning(
271+
"AFK event is too old, can't use to reliably determine AFK state"
272+
)
273+
return None
274+
return events[0]["data"]["status"] == "not-afk"
275+
276+
277+
def hourly():
278+
"""Start a thread that does hourly checkins, on every whole hour that the user is active (not if afk)."""
279+
280+
def checkin_thread():
281+
while True:
282+
# wait until next whole hour
283+
now = datetime.now(timezone.utc)
284+
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(
285+
hours=1
286+
)
287+
sleep_time = (next_hour - now).total_seconds()
288+
logger.debug(f"Sleeping for {sleep_time} seconds")
289+
sleep(sleep_time)
290+
291+
# check if user is afk
292+
active = get_active_status()
293+
if active is None:
294+
logger.warning("Can't determine AFK status, skipping hourly checkin")
295+
continue
296+
if not active:
297+
logger.info("User is AFK, skipping hourly checkin")
298+
continue
299+
300+
checkin()
301+
302+
threading.Thread(target=checkin_thread, daemon=True).start()
229303

230304

231305
if __name__ == "__main__":

0 commit comments

Comments
 (0)