Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/accessiweather/alert_notification_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .display.presentation.formatters import format_display_datetime
from .models import AppSettings, WeatherAlert, WeatherAlerts
from .notifications.toast_notifier import SafeDesktopNotifier
from .weather_event_pipeline import WeatherEvent, WeatherEventDispatcher

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -133,11 +134,13 @@ def __init__(
alert_manager: AlertManager,
notifier: SafeDesktopNotifier | None = None,
settings: AppSettings | None = None,
event_dispatcher: WeatherEventDispatcher | None = None,
):
"""Initialize the instance."""
self.alert_manager = alert_manager
self.notifier = notifier or SafeDesktopNotifier()
self.settings = settings
self.event_dispatcher = event_dispatcher

logger.info("AlertNotificationSystem initialized")

Expand Down Expand Up @@ -260,6 +263,25 @@ async def _send_alert_notification(
play_sound=play_sound,
)

if self.event_dispatcher is not None:
self.event_dispatcher.dispatch_event(
WeatherEvent(
channel="urgent",
location=", ".join(alert.areas[:1]) if alert.areas else "Unknown",
headline=title,
speech_text=message,
change_text=message,
payload={
"reason": reason,
"severity": alert.severity,
"event": alert.event,
"alert_id": alert.get_unique_id(),
},
),
announce=True,
mirror_toast=False,
)

if success:
logger.info(f"[notify] Alert notification sent: {title[:60]!r}")
else:
Expand Down
24 changes: 24 additions & 0 deletions src/accessiweather/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .location_manager import LocationManager
from .ui.main_window import MainWindow
from .weather_client import WeatherClient
from .weather_event_pipeline import WeatherEvent, WeatherEventChannel, WeatherEventDispatcher

# Configure logging
if not logging.getLogger().handlers:
Expand Down Expand Up @@ -132,6 +133,9 @@ def __init__(
# Notification system
self._notifier = None

# Event pipeline (Phase 1)
self.event_dispatcher: WeatherEventDispatcher | None = None

# System tray icon (initialized after main window)
self.tray_icon = None

Expand All @@ -153,6 +157,26 @@ def notifier(self):
def notifier(self, value) -> None:
self._notifier = value

def get_latest_event(self, channel: WeatherEventChannel | None = None) -> WeatherEvent | None:
"""Return latest event, optionally constrained to a channel."""
if not self.event_dispatcher:
return None
return self.event_dispatcher.store.latest(channel=channel)

def get_unread_summary(self) -> dict[str, int]:
"""Return unread event counts for all channels."""
if not self.event_dispatcher:
return {
"urgent": 0,
"now": 0,
"hourly": 0,
"daily": 0,
"discussion": 0,
"system": 0,
"total": 0,
}
return self.event_dispatcher.store.unread_counts()

def OnInit(self) -> bool:
"""Initialize the application (wxPython entry point)."""
logger.info("Starting AccessiWeather application (wxPython)")
Expand Down
11 changes: 10 additions & 1 deletion src/accessiweather/app_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,21 @@ def initialize_components(app: AccessiWeatherApp) -> None:
# Lazy import alert components
from .alert_manager import AlertManager
from .alert_notification_system import AlertNotificationSystem
from .weather_event_pipeline import WeatherEventDispatcher, WeatherEventStore

app.event_dispatcher = WeatherEventDispatcher(
WeatherEventStore(max_size=300),
notifier=app._notifier,
)

config_dir_str = str(app.paths.config)
alert_settings = config.settings.to_alert_settings()
app.alert_manager = AlertManager(config_dir_str, alert_settings)
app.alert_notification_system = AlertNotificationSystem(
app.alert_manager, app._notifier, config.settings
app.alert_manager,
app._notifier,
config.settings,
event_dispatcher=app.event_dispatcher,
)

# Defer weather history service initialization
Expand Down
39 changes: 39 additions & 0 deletions src/accessiweather/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import wx
from wx.lib.sized_controls import SizedFrame, SizedPanel

from ..weather_event_pipeline import WeatherEvent, should_emit_current_conditions_event

if TYPE_CHECKING:
from ..app import AccessiWeatherApp
from ..models.location import Location
Expand Down Expand Up @@ -970,6 +972,7 @@ async def _pre_warm_other_locations(self, current_location: Location) -> None:
def _on_weather_data_received(self, weather_data) -> None:
"""Handle received weather data (called on main thread)."""
try:
previous_weather_data = self.app.current_weather_data
self.app.current_weather_data = weather_data

# Use presenter to create formatted presentation
Expand All @@ -988,6 +991,42 @@ def _on_weather_data_received(self, weather_data) -> None:

self.current_conditions.SetValue(current_text)

# Emit channelized current-conditions event only on meaningful change.
if self.app.event_dispatcher:
previous_current = (
previous_weather_data.current_conditions if previous_weather_data else None
)
should_emit, change_text = should_emit_current_conditions_event(
previous_current,
weather_data.current_conditions,
)
if should_emit and weather_data.current_conditions is not None:
location = self.app.config_manager.get_current_location()
location_name = location.name if location else "Unknown"
condition = weather_data.current_conditions.condition or "Unknown conditions"
temperature = weather_data.current_conditions.temperature
temp_text = (
f", {temperature:.0f} degrees"
if isinstance(temperature, (int, float))
else ""
)
speech_text = f"{location_name}: {condition}{temp_text}. {change_text}".strip()
self.app.event_dispatcher.dispatch_event(
WeatherEvent(
channel="now",
location=location_name,
headline=f"Current conditions: {condition}",
speech_text=speech_text,
change_text=change_text,
payload={
"condition": condition,
"temperature": temperature,
},
),
announce=True,
mirror_toast=False,
)

# Update stale/cached data warning
if presentation.status_messages:
warning_text = " ".join(presentation.status_messages)
Expand Down
168 changes: 168 additions & 0 deletions src/accessiweather/weather_event_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Channelized weather event pipeline primitives (Phase 1)."""

from __future__ import annotations

import json
import uuid
from collections import deque
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Literal

from .models import CurrentConditions
from .screen_reader import ScreenReaderAnnouncer

WeatherEventChannel = Literal["urgent", "now", "hourly", "daily", "discussion", "system"]


@dataclass(slots=True)
class WeatherEvent:
"""A lightweight event envelope for visible and non-visual weather flows."""

id: str = field(default_factory=lambda: str(uuid.uuid4()))
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
channel: WeatherEventChannel = "system"
location: str = "Unknown"
headline: str = ""
speech_text: str = ""
change_text: str = ""
payload: dict[str, Any] = field(default_factory=dict)
read: bool = False


class WeatherEventStore:
"""In-memory ring buffer with unread accounting and cursor helpers."""

def __init__(self, max_size: int = 300) -> None:
"""Create a ring buffer with configurable maximum size."""
self.max_size = max(1, int(max_size))
self._events: deque[WeatherEvent] = deque(maxlen=self.max_size)

def append(self, event: WeatherEvent) -> WeatherEvent:
if len(self._events) == self._events.maxlen and self._events and not self._events[0].read:
# Item will be evicted unread; no per-event index to maintain, so just append.
pass
self._events.append(event)
return event

def latest(self, channel: WeatherEventChannel | None = None) -> WeatherEvent | None:
for event in reversed(self._events):
if channel is None or event.channel == channel:
return event
return None

def get_after(
self, cursor: str | None = None, channel: WeatherEventChannel | None = None
) -> list[WeatherEvent]:
seen_cursor = cursor is None
out: list[WeatherEvent] = []
for event in self._events:
if not seen_cursor:
if event.id == cursor:
seen_cursor = True
continue
if channel is None or event.channel == channel:
out.append(event)
return out

def mark_read(self, event_id: str) -> bool:
for event in self._events:
if event.id == event_id:
event.read = True
return True
return False

def unread_counts(self) -> dict[str, int]:
counts: dict[str, int] = {
"urgent": 0,
"now": 0,
"hourly": 0,
"daily": 0,
"discussion": 0,
"system": 0,
"total": 0,
}
for event in self._events:
if not event.read:
counts[event.channel] += 1
counts["total"] += 1
return counts


class WeatherEventDispatcher:
"""Centralized event writer + announcer + optional toast mirroring."""

def __init__(self, store: WeatherEventStore, notifier: Any | None = None) -> None:
"""Create dispatcher with a store and optional toast notifier."""
self.store = store
self.notifier = notifier
self.announcer = ScreenReaderAnnouncer()

def dispatch_event(
self,
event: WeatherEvent,
*,
announce: bool = True,
mirror_toast: bool = True,
) -> WeatherEvent:
self.store.append(event)

if announce and event.speech_text:
self.announcer.announce(event.speech_text)

if mirror_toast and self.notifier is not None:
message = (
event.change_text or event.speech_text or self._serialize_payload(event.payload)
)
self.notifier.send_notification(
title=event.headline or "Weather Update",
message=message,
timeout=10,
play_sound=False,
)

return event

@staticmethod
def _serialize_payload(payload: dict[str, Any]) -> str:
if not payload:
return ""
try:
return json.dumps(payload, ensure_ascii=False)
except Exception:
return str(payload)


def should_emit_current_conditions_event(
previous: CurrentConditions | None,
current: CurrentConditions | None,
*,
temp_delta_threshold: float = 2.0,
wind_delta_threshold: float = 5.0,
) -> tuple[bool, str]:
"""Return whether a current-conditions event should be emitted and why."""
if current is None:
return False, ""
if previous is None:
return True, "Initial current conditions"

prev_temp = previous.temperature
curr_temp = current.temperature
if prev_temp is not None and curr_temp is not None:
delta = abs(curr_temp - prev_temp)
if delta >= temp_delta_threshold:
return True, f"Temperature changed by {delta:.0f}°"

prev_wind = previous.wind_speed
curr_wind = current.wind_speed
if prev_wind is not None and curr_wind is not None:
wind_delta = abs(curr_wind - prev_wind)
if wind_delta >= wind_delta_threshold:
return True, f"Wind changed by {wind_delta:.0f}"

prev_cond = (previous.condition or "").strip().lower()
curr_cond = (current.condition or "").strip().lower()
if prev_cond and curr_cond and prev_cond != curr_cond:
return True, f"Conditions changed from {previous.condition} to {current.condition}"

return False, ""
Loading
Loading