This repository has been archived on 2026-08-01. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
Timer_original/peak_monitor.py
Ivan Juanes 247ce4c62e Initial commit: Peak Monitor system tray app with Tau development framework
Peak Monitor is a Linux system tray app that monitors two daily peak
periods (09:00-12:00 and 14:00-18:00, Atlantic/Canary time) and sends
desktop notifications when each period starts and ends.

Files:
- peak_monitor.py: Main application (GTK + AyatanaAppIndicator3)
- peak-monitor.desktop: Desktop entry for autostart
- ROADMAP.md: Development roadmap using Tau as the development framework
- .env.example: Environment variable template (API keys)

Tech stack:
- Python 3 with GTK
- Pillow for tray icons
- libnotify for desktop notifications
- Tau coding agent for development assistance
2026-07-11 23:26:54 +01:00

289 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Peak / Valley Time Monitor — system tray app for Linux.
Monitors two daily peak periods (Atlantic/Canary local time):
Morning: 09:00 12:00
Afternoon: 14:00 18:00
Sends a desktop notification when each period starts and ends.
Left-click the tray icon to see time remaining in the current block
(or time until the next peak block).
Requires: python3, python3-gi, gir1.2-gtk-3.0, gir1.2-ayatanaappindicator3-0.1,
python3-pil, libnotify-bin (notify-send)
"""
import datetime
import os
import subprocess
import sys
from zoneinfo import ZoneInfo
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import Gtk, GLib, AyatanaAppIndicator3
from PIL import Image, ImageDraw
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
TZ = ZoneInfo("Atlantic/Canary")
PEAKS = [
("Morning Peak", datetime.time(9, 0), datetime.time(12, 0)),
("Afternoon Peak", datetime.time(14, 0), datetime.time(18, 0)),
]
ICON_SIZE = 48 # px (system tray usually scales this down)
POLL_SECONDS = 15 # how often we re-check the clock
NOTIFY_WINDOW = datetime.timedelta(minutes=1) # grace window to send boundary notification
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now() -> datetime.datetime:
return datetime.datetime.now(TZ)
def _make_icon_png(rgb: tuple[int, int, int]) -> str:
"""Create a plain coloured circle PNG in /tmp and return its path."""
path = f"/tmp/peak_monitor_{rgb[0]}_{rgb[1]}_{rgb[2]}.png"
if os.path.exists(path):
return path # reuse
img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
m = 4 # margin so the circle isn't clipped
draw.ellipse([m, m, ICON_SIZE - m, ICON_SIZE - m], fill=rgb)
img.save(path, "PNG")
return path
def _notify(title: str, body: str) -> None:
"""Fire a desktop notification via notify-send."""
subprocess.run(
["notify-send", title, body],
capture_output=True,
check=False,
)
# ---------------------------------------------------------------------------
# Core logic
# ---------------------------------------------------------------------------
def _current_peak(t: datetime.time) -> tuple[bool, tuple | None]:
"""Return (is_peak, matching_peak_tuple_or_None)."""
for peak in PEAKS:
if peak[1] <= t < peak[2]:
return True, peak
return False, None
def _next_peak_start(now_dt: datetime.datetime) -> datetime.datetime | None:
"""Return the next peak start datetime (today or tomorrow), or None."""
today = now_dt.date()
candidates = []
for peak in PEAKS:
start_dt = datetime.datetime.combine(today, peak[1], tzinfo=TZ)
candidates.append(start_dt)
# also tomorrow's start (in case all today's starts have passed)
candidates.append(start_dt + datetime.timedelta(days=1))
future = [c for c in candidates if c > now_dt]
return min(future) if future else None
def _format_remaining(delta: datetime.timedelta) -> str:
"""Human-friendly timedelta string."""
total_min = int(delta.total_seconds() / 60)
if total_min <= 0:
return "now"
h, m = divmod(total_min, 60)
if h and m:
return f"{h}h {m}m"
if h:
return f"{h}h"
return f"{m}m"
# ---------------------------------------------------------------------------
# GTK + AppIndicator application
# ---------------------------------------------------------------------------
class PeakMonitor:
def __init__(self) -> None:
# -- icons ----------------------------------------------------------
self.icon_green = _make_icon_png((46, 204, 113)) # peak
self.icon_gray = _make_icon_png((149, 165, 166)) # valley
# -- notification dedup ---------------------------------------------
# Keys like "2025-06-15:morning_start"; we clear stale keys on date roll.
self._notified: set[str] = set()
self._last_date: datetime.date | None = None
# -- indicator (mute C-library deprecation warning) -----------------
_saved = os.dup(2)
os.close(2)
os.open(os.devnull, os.O_WRONLY)
try:
self.indicator = AyatanaAppIndicator3.Indicator.new(
"peak-monitor",
self.icon_gray,
AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
)
finally:
os.dup2(_saved, 2)
os.close(_saved)
self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
# -- menu -----------------------------------------------------------
self.menu = Gtk.Menu()
self.status_item = Gtk.MenuItem(label="Starting…")
self.status_item.connect("activate", self._on_status_click)
self.menu.append(self.status_item)
self.menu.append(Gtk.SeparatorMenuItem())
quit_item = Gtk.MenuItem(label="Quit")
quit_item.connect("activate", self._on_quit)
self.menu.append(quit_item)
self.menu.show_all()
self.indicator.set_menu(self.menu)
# -- periodic timer -------------------------------------------------
GLib.idle_add(self._tick) # first tick immediately
GLib.timeout_add_seconds(POLL_SECONDS, self._tick)
# ------------------------------------------------------------------ update
def _tick(self) -> bool:
current = _now()
cur_time = current.time()
cur_date = current.date()
# --- roll daily notification dedup set ---------------------------
if self._last_date != cur_date:
self._notified.clear()
self._last_date = cur_date
in_peak, peak = _current_peak(cur_time)
# --- icon & tooltip ----------------------------------------------
if in_peak:
name, start, end = peak
end_dt = datetime.datetime.combine(cur_date, end, tzinfo=TZ)
remaining = end_dt - current
tooltip = f"{name}{_format_remaining(remaining)} left"
self.indicator.set_icon_full(self.icon_green, tooltip)
self.status_item.set_label(f"{name}: {_format_remaining(remaining)} left")
else:
next_start = _next_peak_start(current)
if next_start:
till = _format_remaining(next_start - current)
tooltip = f"🌙 Valley — next peak in {till}"
self.status_item.set_label(f"Valley — next peak in {till}")
else:
tooltip = "🌙 Valley"
self.status_item.set_label("Valley")
self.indicator.set_icon_full(self.icon_gray, tooltip)
# --- boundary notifications --------------------------------------
self._check_boundaries(current, cur_time, cur_date, in_peak, peak)
return True # keep GLib timeout alive
# ------------------------------------------------------------------ boundaries
def _check_boundaries(
self,
now_dt: datetime.datetime,
now_t: datetime.time,
today: datetime.date,
in_peak: bool,
peak: tuple | None,
) -> None:
"""Fire notify-send at period start/end if within the grace window."""
for name, start_t, end_t in PEAKS:
# --- period start ----------------------------------------------
key_start = f"{today.isoformat()}:{name}:start"
start_dt = datetime.datetime.combine(today, start_t, tzinfo=TZ)
if (
abs(now_dt - start_dt) <= NOTIFY_WINDOW
and key_start not in self._notified
):
_notify(
f"{name} started",
f"{start_t.strftime('%H:%M')} {end_t.strftime('%H:%M')} "
f"(Atlantic/Canary)",
)
self._notified.add(key_start)
# --- period end ------------------------------------------------
key_end = f"{today.isoformat()}:{name}:end"
end_dt = datetime.datetime.combine(today, end_t, tzinfo=TZ)
if (
abs(now_dt - end_dt) <= NOTIFY_WINDOW
and key_end not in self._notified
):
_notify(
f"{name} ended",
f"Peak period {start_t.strftime('%H:%M')} "
f"{end_t.strftime('%H:%M')} is over.",
)
self._notified.add(key_end)
# ------------------------------------------------------------------ actions
def _on_status_click(self, _widget) -> None:
"""Left-click on the status menu item: show a detailed notification."""
current = _now()
cur_time = current.time()
in_peak, peak = _current_peak(cur_time)
if in_peak:
name, start, end = peak
end_dt = datetime.datetime.combine(current.date(), end, tzinfo=TZ)
left = _format_remaining(end_dt - current)
_notify(
f"☀ Currently in {name}",
f"Started {start.strftime('%H:%M')}, "
f"ends {end.strftime('%H:%M')}\n"
f"{left} remaining",
)
else:
next_start = _next_peak_start(current)
if next_start:
till = _format_remaining(next_start - current)
ns_name = next_start.strftime("%A %H:%M")
_notify(
"🌙 Valley (off-peak)",
f"Next peak starts {ns_name}\n{till} until then",
)
else:
_notify("🌙 Valley", "No upcoming peak periods.")
def _on_quit(self, _widget) -> None:
Gtk.main_quit()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
PeakMonitor()
try:
Gtk.main()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()