Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
861a585
feat(weather): add Pirate Weather as a fourth weather data source (#477)
Orinks Mar 18, 2026
fe8041f
feat(auto): wire pirateweather into fusion source priority system
Orinks Mar 18, 2026
bee99f8
feat(weather): add daily summary support + fix coverage gate (#477)
Orinks Mar 18, 2026
ef092db
fix(ui): label API key fields with provider names + fix PW signup URL
Orinks Mar 18, 2026
871d709
fix(ui): show API key fields only for selected data source
Orinks Mar 18, 2026
7eda785
feat(display): reorder forecast layout + configurable hourly hours
Orinks Mar 18, 2026
e07f06d
fix: bump hourly forecast max to 168 hours (7 days)
Orinks Mar 18, 2026
0f2dba9
fix(display): dynamic hourly forecast header based on configured hours
Orinks Mar 18, 2026
a8914a3
fix(config): add hourly_forecast_hours to to_dict/from_dict for persi…
Orinks Mar 18, 2026
a555aa7
refactor: use Open-Meteo for extended forecasts instead of NWS+OM sti…
Orinks Mar 18, 2026
a901d3e
feat: add Pirate Weather API key to onboarding wizard and portable se…
Orinks Mar 18, 2026
781ac66
feat: add minutely precipitation notifications from Pirate Weather (#…
Orinks Mar 18, 2026
2cecd4d
merge: onboarding/portable PW setup (#482) into PW branch
Orinks Mar 18, 2026
e491dad
merge: minutely precipitation notifications (#483) into PW branch
Orinks Mar 18, 2026
df8ee8f
merge: extended forecast cleanup (#481) into PW branch
Orinks Mar 18, 2026
5234774
merge: rebase PW branch onto latest dev (resolve conflicts)
Orinks Mar 18, 2026
0495d42
fix(ui): remove duplicate Pirate Weather API key section in settings
Orinks Mar 18, 2026
8170b1c
fix(ui): remove AVWX key from settings, keep only in aviation dialog
Orinks Mar 18, 2026
85723f0
feat(ui): add Validate API Key button for Pirate Weather
Orinks Mar 18, 2026
6cbae61
fix: default minutely precipitation notifications to enabled
Orinks Mar 18, 2026
d2ba5cb
fix: round wind speed and gust values in PW forecast periods
Orinks Mar 18, 2026
643f002
fix: match from_dict fallback to dataclass default (True) for minutel…
Orinks Mar 18, 2026
afe3f92
feat: add Pirate Weather integration tests with VCR cassettes
Orinks Mar 18, 2026
e6c2588
fix(ui): update data source labels for accuracy
Orinks Mar 19, 2026
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
Binary file removed .coverage
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ htmlcov/

# uv
uv.lock
reports/coverage.xml
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- Main window integration for displaying discussion summaries.
- AI summarization button in the nationwide dialog.
- Shared `NationalDiscussionService` instance with caching.
- Added opt-in Pirate Weather minutely precipitation notifications, including "starting soon" and "stopping soon" toggles that can announce transitions like "Rain starting in 12 minutes."
- Bundled `prismatoid`/`prism` in PyInstaller nightly builds (PR #294).
- Added missing tests for `national_discussion_service.py` to meet coverage gate requirements.
- Updated Antfarm to v0.2.2 and configured feature-dev workflow for 80% diff-coverage.
Expand Down
29 changes: 26 additions & 3 deletions src/accessiweather/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def _maybe_show_portable_missing_keys_hint(self) -> None:
dialog = wx.MessageDialog(
self.main_window,
"This portable copy has no API keys yet.\n\n"
"Visual Crossing weather provider keys can be entered in Settings > Data Sources. "
"Visual Crossing and Pirate Weather provider keys can be entered in Settings > Data Sources. "
"OpenRouter AI keys can be entered in Settings > AI.\n\n"
"You can also create an encrypted key bundle to carry your keys with the portable install.",
"Portable setup hint",
Expand Down Expand Up @@ -506,6 +506,7 @@ def _show_onboarding_readiness_summary(self) -> None:
f"- Location configured: {self._onboarding_status_text(bool(config.locations))}",
f"- OpenRouter key set: {self._onboarding_status_text(self._has_saved_api_key('openrouter_api_key'))}",
f"- Visual Crossing weather provider key set: {self._onboarding_status_text(self._has_saved_api_key('visual_crossing_api_key'))}",
f"- Pirate Weather provider key set: {self._onboarding_status_text(self._has_saved_api_key('pirate_weather_api_key'))}",
*(
[
f"- Portable key bundle created: {self._onboarding_status_text(self._portable_keys_imported_this_session)}"
Expand All @@ -530,7 +531,7 @@ def _maybe_show_first_start_onboarding(self) -> None:
self._run_deferred_startup_update_check()
return

total_steps = 4 if self._portable_mode else 3
total_steps = 5 if self._portable_mode else 4

step1 = wx.MessageDialog(
self.main_window,
Expand Down Expand Up @@ -592,10 +593,23 @@ def _maybe_show_first_start_onboarding(self) -> None:
else:
_wizard_keys["visual_crossing_api_key"] = visual_crossing_key

pirate_weather_key = self._prompt_optional_secret_with_link(
"Pirate Weather provider key (optional)",
f"Step 4 of {total_steps}: Enter your Pirate Weather provider key now, or leave blank to skip.",
"https://pirateweather.net/",
"Get Pirate Weather provider key",
)
if pirate_weather_key is not None and pirate_weather_key:
if not self._portable_mode:
self.config_manager.update_settings(pirate_weather_api_key=pirate_weather_key)
self._maybe_offer_test_key_now("Pirate Weather provider key")
else:
_wizard_keys["pirate_weather_api_key"] = pirate_weather_key

if self._portable_mode and _wizard_keys:
# Keys were entered — prompt for passphrase and write bundle directly.
passphrase = self._prompt_optional_secret(
"Step 4 of 4: Secure your API keys",
"Step 5 of 5: Secure your API keys",
"Enter a passphrase to encrypt your API keys into a portable bundle.\n"
"This bundle travels with the app so your keys work on any machine.\n\n"
"Leave blank to skip (keys will not be saved).",
Expand Down Expand Up @@ -1145,6 +1159,15 @@ def refresh_runtime_settings(self) -> None:
self.weather_client.settings = settings
self.weather_client.data_source = settings.data_source
self.weather_client.alerts_enabled = bool(settings.enable_alerts)
# Reset cached API clients so new keys take effect immediately
self.weather_client._visual_crossing_api_key = getattr( # pragma: no cover
settings, "visual_crossing_api_key", ""
)
self.weather_client._visual_crossing_client = None # pragma: no cover
self.weather_client._pirate_weather_api_key = getattr( # pragma: no cover
settings, "pirate_weather_api_key", ""
)
self.weather_client._pirate_weather_client = None # pragma: no cover

if self.presenter:
self.presenter.settings = settings
Expand Down
7 changes: 4 additions & 3 deletions src/accessiweather/app_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ def initialize_components(app: AccessiWeatherApp) -> None:

# Initialize weather client with lazy imports
data_source = config.settings.data_source if config.settings else "auto"
# Note: visual_crossing_api_key and avwx_api_key are LazySecureStorage objects
# that defer keyring access until first use. We pass them directly to
# WeatherClient which accesses the values lazily when needed.
# Note: visual_crossing_api_key, pirate_weather_api_key and avwx_api_key are
# LazySecureStorage objects that defer keyring access until first use.
lazy_api_key = config.settings.visual_crossing_api_key if config.settings else ""
lazy_pw_api_key = config.settings.pirate_weather_api_key if config.settings else ""
lazy_avwx_key = config.settings.avwx_api_key if config.settings else ""
# Lazy import WeatherDataCache
from .cache import WeatherDataCache
Expand All @@ -60,6 +60,7 @@ def initialize_components(app: AccessiWeatherApp) -> None:
user_agent="AccessiWeather/2.0",
data_source=data_source,
visual_crossing_api_key=lazy_api_key,
pirate_weather_api_key=lazy_pw_api_key,
avwx_api_key=lazy_avwx_key,
settings=config.settings,
offline_cache=offline_cache,
Expand Down
7 changes: 6 additions & 1 deletion src/accessiweather/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,15 @@ def _load_secure_keys(self) -> None:
# In portable mode, API keys live in the bundle — not in keyring.
# Only load non-API-key secrets (e.g. GitHub app credentials) from keyring.
is_portable = getattr(self.app, "_portable_mode", False)
portable_api_keys = {"visual_crossing_api_key", "openrouter_api_key"}
portable_api_keys = {
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
}

secure_keys = [
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
"github_app_id",
"github_app_private_key",
Expand Down
1 change: 1 addition & 0 deletions src/accessiweather/config/import_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

PORTABLE_API_SECRET_KEYS: Final[tuple[str, ...]] = (
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
"avwx_api_key",
)
Expand Down
18 changes: 16 additions & 2 deletions src/accessiweather/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,21 @@ def _validate_and_fix_config(self) -> None:
config_changed = False

# Critical validation: data_source affects weather client selection at startup
valid_sources = ["auto", "nws", "openmeteo", "visualcrossing"]
valid_sources = ["auto", "nws", "openmeteo", "visualcrossing", "pirateweather"]
if settings.data_source not in valid_sources:
self.logger.warning(
f"Invalid data_source '{settings.data_source}', resetting to 'auto'"
)
settings.data_source = "auto"
config_changed = True

if settings.data_source == "pirateweather" and not settings.pirate_weather_api_key:
self.logger.warning(
"Pirate Weather selected but no API key provided, switching to 'auto'"
)
settings.data_source = "auto"
config_changed = True

if settings.data_source == "visualcrossing" and not settings.visual_crossing_api_key:
self.logger.warning(
"Visual Crossing selected but no API key provided, switching to 'auto'"
Expand Down Expand Up @@ -179,11 +186,17 @@ def update_settings(self, **kwargs) -> bool:
config = self._manager.get_config()
# In portable mode, API keys live in the bundle — skip keyring writes for them.
is_portable = getattr(self._manager.app, "_portable_mode", False)
portable_api_keys = {"visual_crossing_api_key", "openrouter_api_key", "avwx_api_key"}
portable_api_keys = {
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
"avwx_api_key",
}

# These keys should be stored in SecureStorage (non-portable, or non-API-key secrets)
secure_keys = {
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
"avwx_api_key",
"github_app_id",
Expand All @@ -196,6 +209,7 @@ def update_settings(self, **kwargs) -> bool:
redacted_keys = {
"github_app_private_key",
"visual_crossing_api_key",
"pirate_weather_api_key",
"openrouter_api_key",
"avwx_api_key",
}
Expand Down
12 changes: 8 additions & 4 deletions src/accessiweather/config/source_priority.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ class SourcePriorityConfig:
"""

# Default priorities by location type
us_default: list[str] = field(default_factory=lambda: ["nws", "openmeteo", "visualcrossing"])
us_default: list[str] = field(
default_factory=lambda: ["nws", "openmeteo", "visualcrossing", "pirateweather"]
)
international_default: list[str] = field(
default_factory=lambda: ["openmeteo", "visualcrossing"]
default_factory=lambda: ["openmeteo", "pirateweather", "visualcrossing"]
)

# Per-field overrides (field_name -> priority list)
Expand Down Expand Up @@ -92,9 +94,11 @@ def from_dict(cls, data: dict) -> SourcePriorityConfig:

"""
return cls(
us_default=data.get("us_default", ["nws", "openmeteo", "visualcrossing"]),
us_default=data.get(
"us_default", ["nws", "openmeteo", "visualcrossing", "pirateweather"]
),
international_default=data.get(
"international_default", ["openmeteo", "visualcrossing"]
"international_default", ["openmeteo", "pirateweather", "visualcrossing"]
),
field_priorities=data.get("field_priorities", {}),
temperature_conflict_threshold=data.get("temperature_conflict_threshold", 5.0),
Expand Down
17 changes: 13 additions & 4 deletions src/accessiweather/display/presentation/forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ def build_forecast(
precision = 0 if round_values else get_temperature_precision(unit_pref)

periods: list[ForecastPeriodPresentation] = []
summary_line = f"Overall: {forecast.summary}" if forecast.summary else None
fallback_lines = [f"Forecast for {location.name}:\n"]
if summary_line:
fallback_lines.append(summary_line)

hourly_hours = getattr(settings, "hourly_forecast_hours", 6) if settings else 6
hourly_hours = max(1, min(hourly_hours, 168))

if hourly_forecast and hourly_forecast.has_data():
hourly = build_hourly_summary(hourly_forecast, unit_pref, settings=settings)
Expand Down Expand Up @@ -235,7 +241,7 @@ def build_forecast(
fallback_lines.append(f"\nForecast generated: {generated_at}")

if hourly:
fallback_lines.insert(1, render_hourly_fallback(hourly))
fallback_lines.append(render_hourly_fallback(hourly, hours=hourly_hours))

# Append cross-source confidence summary when available
confidence_label: str | None = None
Expand All @@ -253,6 +259,7 @@ def build_forecast(
generated_at=generated_at,
fallback_text=fallback_text,
confidence_label=confidence_label,
summary=summary_line,
)


Expand Down Expand Up @@ -291,7 +298,9 @@ def build_hourly_summary(
include_cloud_cover = verbosity_level == "detailed"
include_wind_gust = verbosity_level == "detailed"

for period in hourly_forecast.get_next_hours(6):
hourly_hours = getattr(settings, "hourly_forecast_hours", 6) if settings else 6
hourly_hours = max(1, min(hourly_hours, 168))
for period in hourly_forecast.get_next_hours(hourly_hours):
if not period.has_data():
continue
temperature = format_period_temperature(period, unit_pref, precision)
Expand Down Expand Up @@ -383,9 +392,9 @@ def _resolve_forecast_display_time(
return start_time.astimezone(target_tz)


def render_hourly_fallback(hourly: Iterable[HourlyPeriodPresentation]) -> str:
def render_hourly_fallback(hourly: Iterable[HourlyPeriodPresentation], hours: int = 6) -> str:
"""Render hourly periods into fallback text."""
lines = ["Next 6 Hours:"]
lines = [f"Next {hours} Hours:"]
for period in hourly:
parts = [period.time]
if period.temperature:
Expand Down
5 changes: 3 additions & 2 deletions src/accessiweather/display/presentation/html_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,10 @@ def generate_forecast_html(presentation: ForecastPresentation | None) -> str:
<div class="hourly-temp">{temp}</div>
<div class="hourly-conditions">{conditions}</div>
</div>""")
num_hourly = len(presentation.hourly_periods)
hourly_html = f"""
<section class="hourly-section" aria-label="Next 6 hours forecast">
<h3>Next 6 Hours</h3>
<section class="hourly-section" aria-label="Next {num_hourly} hours forecast">
<h3>Next {num_hourly} Hours</h3>
<div class="hourly-grid" role="list">
{"".join(hourly_items)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/accessiweather/display/weather_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class ForecastPresentation:
generated_at: str | None = None
fallback_text: str = ""
confidence_label: str | None = None
summary: str | None = None


@dataclass(slots=True)
Expand Down
4 changes: 4 additions & 0 deletions src/accessiweather/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
HourlyForecastPeriod,
HourlyUVIndex,
Location,
MinutelyPrecipitationForecast,
MinutelyPrecipitationPoint,
SourceAttribution,
SourceData,
TrendInsight,
Expand All @@ -40,6 +42,8 @@
"HourlyForecast",
"HourlyAirQuality",
"HourlyUVIndex",
"MinutelyPrecipitationPoint",
"MinutelyPrecipitationForecast",
"TrendInsight",
"EnvironmentalConditions",
"AviationData",
Expand Down
Loading
Loading