Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file.
- Alert sounds and sound-pack creation now focus on alert severity instead of a long list of specific alert names, while existing packs can keep their older mappings.

### Fixed
- Reliability fixes now prevent several update, notification, tray tooltip, dialog, NOAA radio, and alert-refresh edge cases from interrupting normal use.
- Detected current locations outside the US now get an editable place name when reverse geocoding can identify the coordinates.
- Editing a location after using "Use my current location" now refreshes the editable name and saved US metadata for the detected point, so locations don't quietly fall back to metric defaults.
- Notification sounds now recover after Windows sleep or hibernation, so update
Expand Down
3 changes: 3 additions & 0 deletions src/accessiweather/alert_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,9 @@ def process_alerts(self, alerts: WeatherAlerts) -> list[tuple[WeatherAlert, str]
# Update global notification time if we're sending any notifications
if notifications_to_send:
self.last_global_notification = current_time
# Roll the hourly counter over at the hour boundary so it reflects
# notifications in the current hour, not a lifetime total.
self._reset_hourly_counter()
self.notifications_this_hour += len(notifications_to_send)

# Save state changes - always save to persist any alert state modifications
Expand Down
5 changes: 4 additions & 1 deletion src/accessiweather/alert_manager_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,7 @@ def should_notify_category(self, event: str) -> bool:
"""Check if we should notify for this event category."""
if not event:
return True
return event.lower() not in self.ignored_categories
# Compare case-insensitively: ignored_categories is populated from
# settings/imports without case normalization, so a stored value like
# "Tornado Warning" must still match the lowercased event.
return event.lower() not in {category.lower() for category in self.ignored_categories}
3 changes: 3 additions & 0 deletions src/accessiweather/api_client/core_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ def _make_request(
response = client.get(
request_url, headers=self.headers, params=params, timeout=10
)
# Buffer the body while the client is still open; .json()
# and .text are accessed below after the client closes.
response.read()
logger.debug(
f"Received response from {request_url} with status code: "
f"{response.status_code}"
Expand Down
1 change: 1 addition & 0 deletions src/accessiweather/cache_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ def _serialize_weather_data(weather: WeatherData) -> dict:
"forecast": _serialize_forecast(weather.forecast),
"hourly_forecast": _serialize_hourly(weather.hourly_forecast),
"discussion": weather.discussion,
"discussion_issuance_time": _serialize_datetime(weather.discussion_issuance_time),
"alerts": _serialize_alerts(weather.alerts),
"environmental": _serialize_environmental(weather.environmental),
"trend_insights": _serialize_trends(weather.trend_insights),
Expand Down
21 changes: 21 additions & 0 deletions src/accessiweather/config/locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ def __init__(
def logger(self) -> logging.Logger:
return self._manager._get_logger()

def _valid_coordinates(self, name: str, latitude: float, longitude: float) -> bool:
"""Return True if the coordinates are within valid geographic bounds."""
try:
lat = float(latitude)
lon = float(longitude)
except (TypeError, ValueError):
self.logger.warning(f"Location {name} has non-numeric coordinates; rejecting")
return False
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
self.logger.warning(
f"Location {name} has out-of-range coordinates ({latitude}, {longitude}); rejecting"
)
return False
return True

def add_location(
self,
name: str,
Expand All @@ -52,6 +67,9 @@ def add_location(
marine_mode: bool = False,
) -> bool:
"""Add a new location if it doesn't already exist."""
if not self._valid_coordinates(name, latitude, longitude):
return False

config = self._manager.get_config()

for existing_location in config.locations:
Expand Down Expand Up @@ -95,6 +113,9 @@ async def add_location_with_enrichment(
those cases the zone fields remain null and the location is still
saved.
"""
if not self._valid_coordinates(name, latitude, longitude):
return False

config = self._manager.get_config()

for existing_location in config.locations:
Expand Down
4 changes: 3 additions & 1 deletion src/accessiweather/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,11 @@ def update_settings(self, **kwargs) -> bool:
}
if is_portable:
secure_keys -= portable_api_keys
# These keys should be redacted in logs
# These keys should be redacted in logs (everything stored as a secret)
redacted_keys = {
"github_app_private_key",
"github_app_id",
"github_app_installation_id",
"pirate_weather_api_key",
"openrouter_api_key",
"avwx_api_key",
Expand Down
30 changes: 24 additions & 6 deletions src/accessiweather/forecast_confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ def _valid_sources(sources: list[SourceData]) -> list[SourceData]:
return [s for s in sources if s.success and s.forecast is not None and s.forecast.has_data()]


def _representative_period(forecast):
"""
Pick a comparable period: the first daytime high, not an overnight low.

Sources don't agree on whether ``periods[0]`` is a daytime "Today" high or
an overnight "Tonight" low (it depends on the time of day a source is
fetched), so comparing ``periods[0]`` blindly can pit a high against a low
and produce a falsely large spread. Prefer the first period that has a
temperature and isn't named like a night period; fall back to the first
period when nothing better is found.
"""
periods = forecast.periods
for period in periods:
if period.temperature is not None and "night" not in (period.name or "").lower():
return period
return periods[0] if periods else None


def calculate_forecast_confidence(sources: list[SourceData]) -> ForecastConfidence:
"""
Compute a confidence level by comparing the first forecast period across sources.
Expand Down Expand Up @@ -111,12 +129,12 @@ def calculate_forecast_confidence(sources: list[SourceData]) -> ForecastConfiden

for s in valid:
assert s.forecast is not None # already filtered above
if s.forecast.periods:
p0 = s.forecast.periods[0]
if p0.temperature is not None:
temps.append(p0.temperature)
if p0.precipitation_probability is not None:
precips.append(p0.precipitation_probability)
period = _representative_period(s.forecast)
if period is not None:
if period.temperature is not None:
temps.append(period.temperature)
if period.precipitation_probability is not None:
precips.append(period.precipitation_probability)

temp_spread = (max(temps) - min(temps)) if len(temps) >= 2 else 0.0
precip_spread = (max(precips) - min(precips)) if len(precips) >= 2 else None
Expand Down
3 changes: 2 additions & 1 deletion src/accessiweather/impact_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from __future__ import annotations

import math
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -76,7 +77,7 @@ def _outdoor_from_conditions(
3. Appends precipitation warning when condition text implies active precipitation.
"""
ref_temp = feels_like_f if feels_like_f is not None else temp_f
if ref_temp is None:
if ref_temp is None or not math.isfinite(ref_temp):
return None

comfort = next(label for upper, label in _OUTDOOR_TEMP_BANDS if ref_temp < upper)
Expand Down
4 changes: 2 additions & 2 deletions src/accessiweather/models/config_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ def from_dict(cls, data: dict) -> AppSettings:
notify_precipitation_likelihood=settings_cls._as_bool(
data.get("notify_precipitation_likelihood"), False
),
precipitation_likelihood_threshold=float(
data.get("precipitation_likelihood_threshold", 0.5)
precipitation_likelihood_threshold=settings_cls._as_float(
data.get("precipitation_likelihood_threshold"), 0.5
),
github_backend_url=data.get("github_backend_url", ""),
alert_radius_type=data.get("alert_radius_type", "county"),
Expand Down
9 changes: 7 additions & 2 deletions src/accessiweather/models/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,13 @@ def validate_on_access(self, setting_name: str) -> bool:
setattr(settings, setting_name, "standard")

elif setting_name == "update_channel":
valid_channels = {"stable", "beta", "dev"}
if value not in valid_channels:
# Canonical channels are "stable" and "nightly". "dev"/"beta" are
# legacy aliases for the non-stable channel — migrate them to
# "nightly" rather than resetting to stable (which would silently
# downgrade a nightly user).
if value in {"dev", "beta"}:
setattr(settings, setting_name, "nightly")
elif value not in {"stable", "nightly"}:
setattr(settings, setting_name, "stable")

elif setting_name == "time_display_mode":
Expand Down
15 changes: 14 additions & 1 deletion src/accessiweather/noaa_radio/stream_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,17 @@ def prewarm_cache(self) -> None:

# WeatherIndex requires call sign specific queries, so we can't
# pre-warm all at once - this would require knowing all station
# call signs ahead of time. The cache will warm on first play.
# call signs ahead of time. Use prewarm_stations() to warm a known set.

def prewarm_stations(self, call_signs: list[str]) -> None:
"""
Pre-warm per-call-sign stream URL caches for a set of stations.

Resolves stream URLs for each call sign so a later, main-thread
``get_stream_urls`` lookup (e.g. when the user presses Play) hits a warm
cache instead of blocking on a network request. Intended to be called
from a background thread. Safe to call multiple times.
"""
for call_sign in call_signs:
with suppress(Exception):
self.get_stream_urls(call_sign)
3 changes: 3 additions & 0 deletions src/accessiweather/notifications/toast_notifier_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ def __init__(
logger.info("ToastedWindowsNotifier initialized (app_id=%s)", WINDOWS_APP_USER_MODEL_ID)
# Eagerly start worker thread to avoid first-notification delay
self._ensure_worker()
# Start the watchdog so a dead worker thread is detected and restarted;
# without this, toasts silently stop until the app is relaunched.
self._start_watchdog()

# -- worker thread management ------------------------------------------

Expand Down
4 changes: 4 additions & 0 deletions src/accessiweather/notifications/weather_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ def alert_priority(alert):
sent_time = isoparse(sent_str) if sent_str else datetime.min.replace(tzinfo=UTC)
except Exception:
sent_time = datetime.min.replace(tzinfo=UTC)
# Normalize to tz-aware so sorting never mixes naive and aware
# datetimes (which raises TypeError and would drop all notifications).
if sent_time.tzinfo is None:
sent_time = sent_time.replace(tzinfo=UTC)

# Return tuple for sorting: (severity_score, sent_time)
# Higher severity score and more recent time are preferred
Expand Down
23 changes: 15 additions & 8 deletions src/accessiweather/openmeteo_forecast_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import logging
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, timedelta, timezone
from typing import Any

from .openmeteo_client import OpenMeteoApiClient
Expand Down Expand Up @@ -38,18 +38,25 @@ def map_forecast(

for i, date_str in enumerate(dates):
try:
# Parse the date - convert from local time to UTC
date_str_utc = parse_datetime(date_str, utc_offset_seconds)
if date_str_utc:
date_obj = datetime.fromisoformat(date_str_utc)
else:
# Fallback to treating as UTC if parsing fails
# Open-Meteo daily "time" values are calendar dates (e.g.
# "2026-05-28") already in the location's local timezone. Treat
# them as local dates so the weekday label and the 6am/6pm
# period boundaries are correct. Converting to UTC (as the
# hourly path does) would shift the day backwards for locations
# east of UTC and place the boundaries in UTC rather than local.
try:
date_obj = datetime.fromisoformat(date_str)
except ValueError:
date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
if date_obj.tzinfo is None and utc_offset_seconds is not None:
date_obj = date_obj.replace(
tzinfo=timezone(timedelta(seconds=utc_offset_seconds))
)

# Create day and night periods (NWS style)
day_period = {
"number": i * 2 + 1,
"name": date_obj.strftime("%A") if i == 0 else date_obj.strftime("%A"),
"name": date_obj.strftime("%A"),
"startTime": date_obj.replace(hour=6).isoformat(),
"endTime": date_obj.replace(hour=18).isoformat(),
"isDaytime": True,
Expand Down
5 changes: 5 additions & 0 deletions src/accessiweather/services/community_soundpack_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,11 @@ async def _download_repo_pack(
continue
rel_path = item.get("path") or ""
target_path = staging_dir / rel_path
# Defense-in-depth: ensure a malicious/unexpected tree path can't
# escape the staging directory (path traversal / zip-slip).
staging_root = staging_dir.resolve()
if not target_path.resolve().is_relative_to(staging_root):
raise RuntimeError(f"Unsafe path in repository tree: {rel_path!r}")
target_path.parent.mkdir(parents=True, exist_ok=True)
raw_url = (
f"https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/"
Expand Down
Loading