commit e982d4d6aaf6ffd37098ab773650593da0c2b4f6 Author: Stu Leak Date: Sun Nov 9 14:30:21 2025 -0500 Skyfeed v0.1.0.0 - initial scaffolding, docs, and architecture diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/assets/fonts/Modeseven.ttf b/assets/fonts/Modeseven.ttf new file mode 100755 index 0000000..87c9280 Binary files /dev/null and b/assets/fonts/Modeseven.ttf differ diff --git a/cache/alert.json b/cache/alert.json new file mode 100644 index 0000000..3a980a1 --- /dev/null +++ b/cache/alert.json @@ -0,0 +1,9 @@ +[ + { + "type": "Rainfall Warning", + "title": "Rainfall Warning \u2014 Environment Canada", + "description": "Expect 25\u201340 mm by evening. Heavy at times.", + "issued": "2025-11-09 13:50 EST", + "expires": "2025-11-10 06:00 EST" + } +] \ No newline at end of file diff --git a/cache/meta.json b/cache/meta.json new file mode 100644 index 0000000..e69de29 diff --git a/cache/weather.json b/cache/weather.json new file mode 100644 index 0000000..3001b7c --- /dev/null +++ b/cache/weather.json @@ -0,0 +1,13 @@ +{ + "location": "Cornwall, ON", + "temperature_c": 21, + "feels_like_c": 22, + "wind_dir": "W", + "wind_kph": 10, + "humidity": 55, + "pressure_kpa": 101.3, + "visibility_km": 24, + "condition": "Clear Sky", + "condition_code": "clear", + "timestamp": "14:05 EST" +} \ No newline at end of file diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..c20f1c8 --- /dev/null +++ b/config/config.json @@ -0,0 +1,35 @@ +{ + "Version": "1.0.0", + "Language": "en_CA", + "Theme": "dark", + "RefreshMinutes": 15, + "EnableCache": true, + "CacheDurationMinutes": 30, + + "ManualLocation": { + "Enabled": false, + "City": "Toronto", + "Province": "ON", + "Latitude": 43.7, + "Longitude": -79.4 + }, + + "Units": { + "Temperature": "C", + "Speed": "km/h", + "Pressure": "kPa", + "Distance": "km" + }, + + "Display": { + "ShowClock": true, + "ShowAlerts": true, + "AlertBorder": true, + "IconScale": 4 + }, + + "Advanced": { + "DeveloperMode": false, + "LogLevel": "INFO" + } +} diff --git a/docs/API/cli.md b/docs/API/cli.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/API/core.md b/docs/API/core.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/API/localization.md b/docs/API/localization.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/API/ui.md b/docs/API/ui.md new file mode 100644 index 0000000..eeef2f0 --- /dev/null +++ b/docs/API/ui.md @@ -0,0 +1,119 @@ +# Skyfeed UI API Reference +Leak Technologies — Developer Visual Reference + +This document defines how Skyfeed renders Unicode icons and text using the +skyfeed_default theme (black terminal aesthetic). It ensures visual +consistency between the CLI mode and the graphical UI. + +---------------------------------------------------------------------- +Base Theme Mapping +---------------------------------------------------------------------- + +Role | Colour | Usage +-------------------|------------|--------------------------------------- +Background | #000000 | Terminal canvas, alert frame background +Foreground Text | #E0E0E0 | General text, city name, data values +Primary Accent | #00BFFF | Weather icons, temperature emphasis +Secondary Accent | #FFD300 | Headings, highlights, sun indicators +Advisory / Alert | #FF4040 | Red border and alert messages +Advisory Text | #FFA500 | Amber warning for travel/advisory notices + +Font: assets/fonts/Modeseven.ttf (Teletext-style monospace, ~20pt nominal) +Icon scale: Display.IconScale in config (default 4x) + +---------------------------------------------------------------------- +Layout Overview +---------------------------------------------------------------------- + +Minimal View + ++-------------------------------------------------------------------+ +| Cornwall, ON ☀ | +| Updated: 14:05 EST 21°C | +| Clear Sky | +| Feels: 22°C Wind: W 10 km/h RH: 55% | +| Pressure: 101.3 kPa Visibility: 24 km | ++-------------------------------------------------------------------+ + +- Left column: text data +- Right column: enlarged Unicode weather icon +- Border: red (#FF4040) if an alert is active; black otherwise + +Fullscreen View (planned) + +Fullscreen will display extended conditions, forecasts, and alerts in a +Teletext-inspired grid using the same colour mapping. + +---------------------------------------------------------------------- +Weather Icon Colours +---------------------------------------------------------------------- + +Condition | Symbol | Foreground | Example note +--------------------|--------|-------------|------------------------------ +Clear / Sunny | ☀ | #FFD300 | Bright yellow on black +Mostly Cloudy | 🌥 | #00BFFF | Sky blue on black +Rain / Showers | 🌧 | #00BFFF | Blue icon, white text +Snow / Flurries | ❄ | #E0E0E0 | White icon +Freezing Rain | 🧊 | #00FFFF | Cyan tint +Fog / Mist | 🌫 | #AAAAAA | Soft grey +Wind / Gale | 💨 | #00BFFF | Blue accent +Thunderstorm | ⛈ | #FFD300 | Yellow lightning emphasis +Tornado / Funnel | 🌪 | #FF4040 | Red icon +Hail / Ice Pellets | 🧊 | #00FFFF | Cyan tint + +---------------------------------------------------------------------- +Alert Level Rendering +---------------------------------------------------------------------- + +Severity | Symbol | Border | Text Colour | Meaning +-----------|--------|----------|--------------|----------------------------- +Warning | ⚠ | #FF4040 | White | Action likely required +Watch | 👁 | #FFA500 | White | Conditions favourable +Advisory | ⚡ | #FFD300 | White | Minor / travel risk +Statement | ℹ | #00BFFF | White | Informational +None / OK | ✓ | #00FF00 | White | Normal conditions + +When an alert is active: + - The frame border colour reflects severity. + - Alert text appears below the main conditions in the same hue. + +---------------------------------------------------------------------- +Time and Refresh +---------------------------------------------------------------------- + +- The window title updates each second, e.g. Skyfeed — 14:27:05 +- Weather refresh interval defaults to 15 minutes (RefreshMinutes in config) + +---------------------------------------------------------------------- +Design Goals +---------------------------------------------------------------------- + +Principle | Description +-------------------|--------------------------------------------------------- +No Branding in UI | Skyfeed presents pure data; branding stays in backend. +MS-DOS Safe | Colours and Unicode symbols render in text-only environments. +Retro-Futuristic | Ceefax / MS-DOS-inspired clarity. +Zero-Distraction | No animations, no bitmap icons; Unicode glyphs only. +B&W Readable | Icons remain legible without colour. + +---------------------------------------------------------------------- +Developer Integration +---------------------------------------------------------------------- + +Access icons programmatically: + from src.utils.weather_symbols import get_icon + symbol = get_icon("rain") + +Use with theme: + from src.ui.theme import get_color + fg = get_color("fg_primary") + bg = get_color("bg") + +Localization: + from src.localization.localization_manager import LocalizationManager + loc = LocalizationManager("fr_CA") + label = loc.get_label("temperature") + +---------------------------------------------------------------------- +Maintainer: Leak Technologies +Revision: v0.1.0 (November 2025) diff --git a/docs/API/utils.md b/docs/API/utils.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..7546269 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [0.1.0] - 2025-11-09 +- Initial project scaffolding and docs +- Weather icons + UI docs (terminal aesthetic) +- Localization scaffolding (en_CA, fr_CA, iu_CA) +- Core modules: config, location, weather client, symbols diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/DESIGN/layout_spec.md b/docs/DESIGN/layout_spec.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/DESIGN/theme_reference.md b/docs/DESIGN/theme_reference.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/DESIGN/visual_style.md b/docs/DESIGN/visual_style.md new file mode 100644 index 0000000..b11cc22 --- /dev/null +++ b/docs/DESIGN/visual_style.md @@ -0,0 +1,186 @@ +# Skyfeed Visual Style Guide +**Leak Technologies — Terminal Weather Aesthetic** + +This document defines the Unicode-based icon system used throughout Skyfeed. +It ensures full coverage for Environment Canada’s weather, hazard, and advisory conditions. + +All symbols are **terminal-safe**, **monospace-friendly**, and intentionally minimalist — +reflecting Skyfeed’s philosophy of *pure data without visual clutter*. + +--- + +## 🌤️ Weather & Sky Conditions + +| Code | Symbol | Meaning | +|------|:------:|---------| +| clear / sunny | ☀ | Clear sky / sunshine | +| mostly_clear / mainly_clear | 🌤 | Few clouds | +| partly_cloudy | ⛅ | Partial cloud cover | +| mostly_cloudy | 🌥 | Cloudy with breaks | +| cloudy / overcast | ☁ | Fully overcast | +| night_clear | 🌙 | Clear night sky | +| night_partly_cloudy | 🌃 | Partial clouds at night | +| night_cloudy | ☁ | Cloudy night | + +--- + +## 🌧️ Rain & Precipitation + +| Code | Symbol | Meaning | +|------|:------:|---------| +| rain / showers | 🌧 | Steady or intermittent rain | +| light_rain | 🌦 | Light rainfall / isolated showers | +| heavy_rain / rain_heavy | 🌧 | Heavy or prolonged rain | +| drizzle | 💧 | Light drizzle | +| freezing_rain / freezing_drizzle | 🧊 | Freezing precipitation | +| freezing_spray | 🧊 | Sea spray or freezing rain near coasts | +| mixed_rain_snow / mixed_precipitation | 🌧❄ | Mixed or changing precipitation | +| rainfall_warning | 🌧 | Heavy rainfall advisory | + +--- + +## ❄️ Snow / Ice / Winter Weather + +| Code | Symbol | Meaning | +|------|:------:|---------| +| snow | ❄ | General snowfall | +| flurries / snow_grains | ❆ | Light flurries or grains | +| heavy_snow | ❅ | Intense snowfall | +| blowing_snow / drifting_snow | 🌬 | Snow driven by wind | +| ice_pellets / sleet / graupel | 🧊 | Ice pellets / sleet / graupel | +| hail | 🧊 | Hailstones | +| frost | ❄ | Frost or frost advisories | +| black_ice | ⚫ | Black ice conditions | +| snow_pellets | ❄ | Fine ice particles | + +--- + +## ⛈️ Thunderstorms & Severe Weather + +| Code | Symbol | Meaning | +|------|:------:|---------| +| thunderstorm / thundershowers | ⛈ | Thunderstorms or heavy showers | +| lightning | ⚡ | Lightning observed | +| tstorm / storm | ⛈ | Electrical storms | +| funnel_cloud / waterspout / tornado | 🌪 | Tornadic activity or funnel clouds | + +--- + +## 🌬️ Wind / Gale / Tropical Systems + +| Code | Symbol | Meaning | +|------|:------:|---------| +| wind / strong_wind | 💨 | Strong or gusty wind | +| gusty | 💨 | Occasional gusts | +| gale / gale_warning | 💨 | Gale-force winds | +| wind_chill | 🥶 | Wind chill warning | +| hurricane / cyclone / typhoon / tropical_storm | 🌀 | Tropical or post-tropical storm | + +--- + +## 🌫️ Visibility & Air Quality + +| Code | Symbol | Meaning | +|------|:------:|---------| +| fog / dense_fog | 🌫 | Fog or dense fog | +| freezing_fog | 🌫 | Freezing fog | +| mist / haze | 〰 | Reduced visibility | +| smoke / ash / dust / blowing_dust | 💨 | Airborne particulates (smoke, ash, dust) | +| smog | 🌫 | Air pollution / smog | +| low_visibility / poor_visibility | 👁‍🗨 | Travel visibility reduced | +| air_quality / air_pollution / air_quality_alert | 🚭 | Air quality advisory | + +--- + +## 🌡️ Temperature Extremes + +| Code | Symbol | Meaning | +|------|:------:|---------| +| cold / extreme_cold | 🥶 | Cold or extreme cold warning | +| hot / heat / heat_wave | 🌡 | Hot temperatures | +| heat_warning / extreme_heat | 🔥 | Heat alert or extreme heat | + +--- + +## 🌊 Hydrological / Flood / Marine + +| Code | Symbol | Meaning | +|------|:------:|---------| +| flood / flash_flood | 🌊 | Flooding / flash flood | +| flood_warning | 🌊 | Official flood warning | +| storm_surge / tsunami / rip_current | 🌊 | Coastal or tidal hazard | +| marine_warning / wave | ⚓ | Marine advisory | + +--- + +## 🚗 Travel / Road / Ice Hazards + +| Code | Symbol | Meaning | +|------|:------:|---------| +| travel_advisory | 🚗 | Hazardous travel conditions | +| black_ice_warning / slippery_roads | ⚫ | Black ice / slippery roads | +| road_closure | ⛔ | Road closed due to conditions | +| freezing_rain_warning | 🧊 | Freezing rain warning | + +--- + +## 🔥 Fire / Environmental Hazards + +| Code | Symbol | Meaning | +|------|:------:|---------| +| wildfire / forest_fire | 🔥 | Wildfire or forest fire | +| fire_weather | 🔥 | Elevated fire risk | + +--- + +## ⚠️ Alerts / Advisories / Statements + +| Code | Symbol | Meaning | +|------|:------:|---------| +| alert | ⚠ | General alert | +| advisory | ⚡ | Advisory (lower severity) | +| watch | 👁 | Watch (monitor closely) | +| warning | ⚠ | Warning (take action) | +| statement / special_weather_statement | ℹ | General information statement | +| hazardous_weather | ☣ | Dangerous atmospheric conditions | +| danger | ☠ | Extreme hazard | +| ok | ✓ | Normal / no hazard | +| unknown | ◌ | Unknown or unclassified | + +--- + +## 🧱 Design Intent + +- **No branding inside the display.** + Skyfeed’s presentation is pure data — branding belongs on the backend. + +- **Colour palette:** + - Foreground accent: `#00BFFF` (sky blue) + - Secondary highlight: `#FFD300` (sunny yellow) + - Warnings: `#FF4040` (alert red) + - Background: `#000000` (pure black) + These colours correspond to the *skyfeed_default* theme in `src/ui/theme.py`. + +- **Typeface:** + `assets/fonts/Modeseven.ttf` — a Teletext-era monospaced bitmap font. + +- **Visual tone:** + “Modern retro” — inspired by Ceefax and MS-DOS, emphasizing clarity and precision. + +--- + +## 🧩 Developer Reference + +Each `condition_code` from +`src/core/weather_client.py → _normalize_condition()` +maps one-to-one with the `WEATHER_ICONS` dictionary. + +If a new weather phrase appears in the Environment Canada feeds: +1. Add the normalized `condition_code` in `weather_client.py`. +2. Add its Unicode equivalent in `weather_symbols.py`. +3. Update this document. + +--- + +**Maintainer:** Leak Technologies +**Revision:** v1.0.0 (November 2025) diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f7a875 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1 @@ +version = "0.1.0" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1cb0b9d Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/cli/__init__.py b/src/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli/__pycache__/__init__.cpython-313.pyc b/src/cli/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9825c31 Binary files /dev/null and b/src/cli/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/cli/__pycache__/main.cpython-313.pyc b/src/cli/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..cab50f8 Binary files /dev/null and b/src/cli/__pycache__/main.cpython-313.pyc differ diff --git a/src/cli/main.py b/src/cli/main.py new file mode 100644 index 0000000..c0322f2 --- /dev/null +++ b/src/cli/main.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Skyfeed CLI +----------- +Command-line entrypoint for fetching, viewing, and testing Skyfeed data. + +Usage: + skyfeed fetch # Fetch current weather and alerts + skyfeed alerts # Display active weather alerts + skyfeed ui # Launch graphical UI (terminal-style window) +""" + +import argparse +import sys +from src.core import weather_client, alert_parser, cache_manager +from src.ui import app as ui_app + + +def main(): + parser = argparse.ArgumentParser(description="Skyfeed command-line interface") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("fetch", help="Fetch current weather and alerts") + sub.add_parser("alerts", help="Display active alerts from cache") + sub.add_parser("ui", help="Launch graphical Skyfeed window") + + args = parser.parse_args() + + if args.command == "fetch": + data = weather_client.fetch_current() + alerts = alert_parser.fetch_alerts() + cache_manager.write_weather(data) + cache_manager.write_alerts(alerts) + print("Weather and alerts updated.") + sys.exit(0) + + elif args.command == "alerts": + alerts = cache_manager.read_alerts() + if not alerts: + print("No active alerts.") + else: + for alert in alerts: + print(f"⚠ {alert['title']}\n{alert['description']}\n") + sys.exit(0) + + elif args.command == "ui": + ui_app.launch() + sys.exit(0) + + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/src/config/config_manager.py b/src/config/config_manager.py new file mode 100644 index 0000000..acaa2db --- /dev/null +++ b/src/config/config_manager.py @@ -0,0 +1,141 @@ +""" +File: src/config/config_manager.py +---------------------------------- +Skyfeed Configuration Manager +Handles reading, validating, and writing user configuration. + +This module provides: + - Automatic loading of config/config.json + - Default configuration creation if missing + - Accessor methods (get, set, save) + - Integration with LocalizationManager +""" + +import os +import json +from typing import Any, Dict +from src.localization.localization_manager import LocalizationManager + + +CONFIG_PATH = os.path.expanduser("~/Projects/Skyfeed/config/config.json") + + +class ConfigManager: + def __init__(self): + self.config: Dict[str, Any] = {} + self.localization = None + self.load_config() + + # ------------------------------------------------------------ + # Load or create config + # ------------------------------------------------------------ + def load_config(self): + """Load config.json, creating defaults if not found.""" + if not os.path.exists(CONFIG_PATH): + print(f"[INFO] Creating default configuration at {CONFIG_PATH}") + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + self.config = self.default_config() + self.save_config() + else: + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + self.config = json.load(f) + except Exception as e: + print(f"[WARN] Failed to read config.json ({e}); using defaults.") + self.config = self.default_config() + + # Initialize localization + lang_code = self.config.get("Language", "en_CA") + self.localization = LocalizationManager(lang_code) + + # ------------------------------------------------------------ + # Default configuration + # ------------------------------------------------------------ + def default_config(self) -> Dict[str, Any]: + """Return a safe default configuration dictionary.""" + return { + "Version": "1.0.0", + "Language": "en_CA", # en_CA, fr_CA, iu_CA + "Theme": "dark", # dark, light, retro + "RefreshMinutes": 15, # Weather update frequency + "EnableCache": True, # Use cached data if available + "CacheDurationMinutes": 30, # Cache validity period + "ManualLocation": { + "Enabled": False, + "City": "Toronto", + "Province": "ON", + "Latitude": 43.7, + "Longitude": -79.4 + }, + "Units": { + "Temperature": "C", + "Speed": "km/h", + "Pressure": "kPa", + "Distance": "km" + }, + "Display": { + "ShowClock": True, + "ShowAlerts": True, + "AlertBorder": True, + "IconScale": 4 + }, + "Advanced": { + "DeveloperMode": False, + "LogLevel": "INFO" + } + } + + # ------------------------------------------------------------ + # Getters and setters + # ------------------------------------------------------------ + def get(self, key_path: str, default: Any = None) -> Any: + """ + Get a nested config value by dot path. + Example: get("ManualLocation.City") + """ + node = self.config + for part in key_path.split("."): + if isinstance(node, dict) and part in node: + node = node[part] + else: + return default + return node + + def set(self, key_path: str, value: Any): + """ + Set a nested config value by dot path. + Example: set("Theme", "light") + """ + parts = key_path.split(".") + node = self.config + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = value + + # ------------------------------------------------------------ + # Save + # ------------------------------------------------------------ + def save_config(self): + """Write configuration to disk safely.""" + try: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + print(f"[INFO] Configuration saved to {CONFIG_PATH}") + except Exception as e: + print(f"[ERROR] Failed to save configuration: {e}") + + # ------------------------------------------------------------ + # Localization Access + # ------------------------------------------------------------ + def get_localized(self, *path: str) -> str: + """Shortcut to fetch localized labels via active language.""" + if self.localization: + return self.localization.get_label(*path) + return "?" + + def switch_language(self, new_lang: str): + """Switch active language and reload localization.""" + self.set("Language", new_lang) + self.localization = LocalizationManager(new_lang) + self.save_config() + print(f"[INFO] Language switched to {self.localization.get_language_name()}") diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4ad38f1 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/__pycache__/alert_parser.cpython-313.pyc b/src/core/__pycache__/alert_parser.cpython-313.pyc new file mode 100644 index 0000000..5a5c105 Binary files /dev/null and b/src/core/__pycache__/alert_parser.cpython-313.pyc differ diff --git a/src/core/__pycache__/cache_manager.cpython-313.pyc b/src/core/__pycache__/cache_manager.cpython-313.pyc new file mode 100644 index 0000000..2d9fd3d Binary files /dev/null and b/src/core/__pycache__/cache_manager.cpython-313.pyc differ diff --git a/src/core/__pycache__/weather_client.cpython-313.pyc b/src/core/__pycache__/weather_client.cpython-313.pyc new file mode 100644 index 0000000..b2a89bd Binary files /dev/null and b/src/core/__pycache__/weather_client.cpython-313.pyc differ diff --git a/src/core/alert_parser.py b/src/core/alert_parser.py new file mode 100644 index 0000000..b962f0b --- /dev/null +++ b/src/core/alert_parser.py @@ -0,0 +1,17 @@ +""" +Alert Parser +------------ +Fetches and normalizes active alerts (stub for now). +""" + +def fetch_alerts(): + # Placeholder alert for demonstration + return [ + { + "type": "Rainfall Warning", + "title": "Rainfall Warning — Environment Canada", + "description": "Expect 25–40 mm by evening. Heavy at times.", + "issued": "2025-11-09 13:50 EST", + "expires": "2025-11-10 06:00 EST" + } + ] diff --git a/src/core/cache_manager.py b/src/core/cache_manager.py new file mode 100644 index 0000000..4864c0b --- /dev/null +++ b/src/core/cache_manager.py @@ -0,0 +1,30 @@ +""" +Cache Manager +------------- +Handles reading/writing of cached weather and alert data. +""" + +import json +from pathlib import Path + +CACHE_DIR = Path(__file__).resolve().parents[2] / "cache" +WEATHER_FILE = CACHE_DIR / "weather.json" +ALERT_FILE = CACHE_DIR / "alert.json" + +def write_weather(data): + CACHE_DIR.mkdir(exist_ok=True) + WEATHER_FILE.write_text(json.dumps(data, indent=2)) + +def write_alerts(alerts): + CACHE_DIR.mkdir(exist_ok=True) + ALERT_FILE.write_text(json.dumps(alerts, indent=2)) + +def read_weather(): + if WEATHER_FILE.exists(): + return json.loads(WEATHER_FILE.read_text()) + return {} + +def read_alerts(): + if ALERT_FILE.exists(): + return json.loads(ALERT_FILE.read_text()) + return [] diff --git a/src/core/location_manager.py b/src/core/location_manager.py new file mode 100644 index 0000000..e2bf788 --- /dev/null +++ b/src/core/location_manager.py @@ -0,0 +1,122 @@ +""" +File: src/core/location_manager.py +---------------------------------- +Skyfeed Location Manager +Handles both manual and automatic (IP-based) location resolution +to determine the closest Environment Canada weather station. +""" + +import json +import os +import requests +from datetime import datetime +from src.config.config_manager import ConfigManager + +CACHE_FILE = os.path.expanduser("~/Projects/Skyfeed/cache/meta.json") + +# Default fallback +DEFAULT_LOCATION = { + "city": "Ottawa", + "province": "ON", + "latitude": 45.4215, + "longitude": -75.6992, + "method": "default", + "timestamp": None +} + +# ------------------------------------------------------------ +# Cache handling +# ------------------------------------------------------------ +def get_cached_location() -> dict | None: + """Return cached location if it exists and is valid.""" + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if "city" in data and "latitude" in data: + return data + except Exception: + pass + return None + + +def save_cached_location(data: dict): + """Save resolved location to cache with timestamp.""" + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + data["timestamp"] = datetime.now().isoformat(timespec="seconds") + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +# ------------------------------------------------------------ +# Resolution pipeline +# ------------------------------------------------------------ +def resolve_location() -> dict: + """ + Determine current location: + 1. Manual (from config) + 2. Cached + 3. IP lookup (ipapi.co) + 4. Fallback default + """ + cfg = ConfigManager() + manual = cfg.get("ManualLocation") + + # 1. Manual override + if manual.get("Enabled"): + data = { + "city": manual.get("City"), + "province": manual.get("Province"), + "latitude": manual.get("Latitude"), + "longitude": manual.get("Longitude"), + "method": "manual" + } + save_cached_location(data) + print(f"[INFO] Using manual location: {data['city']}, {data['province']}") + return data + + # 2. Cached + cached = get_cached_location() + if cached: + print(f"[INFO] Using cached location: {cached['city']}, {cached['province']}") + return cached + + # 3. IP-based lookup + try: + print("[INFO] Resolving location via IP lookup...") + response = requests.get("https://ipapi.co/json/", timeout=5) + if response.status_code == 200: + info = response.json() + data = { + "city": info.get("city", "Unknown"), + "province": info.get("region_code", ""), + "latitude": info.get("latitude"), + "longitude": info.get("longitude"), + "method": "ip" + } + save_cached_location(data) + print(f"[INFO] Location resolved via IP: {data['city']}, {data['province']}") + return data + except Exception as e: + print(f"[WARN] IP lookup failed: {e}") + + # 4. Fallback + print(f"[WARN] Falling back to default location: {DEFAULT_LOCATION['city']}") + save_cached_location(DEFAULT_LOCATION) + return DEFAULT_LOCATION + + +# ------------------------------------------------------------ +# Future expansion +# ------------------------------------------------------------ +def nearest_station(latitude: float, longitude: float) -> dict: + """ + Placeholder: determine nearest Environment Canada station. + Will later use Environment Canada's GeoJSON station list. + """ + # TODO: Fetch station list and compute nearest coordinates + return { + "station_id": "ON-40", + "name": "Ottawa Macdonald–Cartier Int'l", + "distance_km": 0.0 + } diff --git a/src/core/weather_client.py b/src/core/weather_client.py new file mode 100644 index 0000000..ac7ea1e --- /dev/null +++ b/src/core/weather_client.py @@ -0,0 +1,375 @@ +""" +File: src/core/weather_client.py +-------------------------------- +Skyfeed Weather Client +Fetches, normalizes, and caches Environment Canada weather + alerts. + +Key features: + - Parses EC citypage XML (current conditions) and province Atom alerts + - Normalizes EC condition phrases → stable condition_code for icons + - Handles many edge cases (hail, ice pellets, mixed precip, fog variants, + blowing/drifting snow, freezing spray, smoke/ash/dust, funnel cloud, waterspout) + - Caches to disk for resiliency/offline use +""" + +import os +import json +import requests +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone + +from src.core.location_manager import resolve_location, nearest_station + +CACHE_DIR = os.path.expanduser("~/Projects/Skyfeed/cache") +WEATHER_CACHE = os.path.join(CACHE_DIR, "weather.json") +ALERT_CACHE = os.path.join(CACHE_DIR, "alert.json") + +EC_WEATHER_BASE = "https://dd.weather.gc.ca/citypage_weather/xml" +EC_ALERTS_BASE = "https://dd.weather.gc.ca/alerts/cap" + +# ------------------------------ +# Helpers: file I/O +# ------------------------------ +def _save_json(path: str, data: dict): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + +def _load_json(path: str) -> dict | None: + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + return None + +def _to_float(x, default=None): + try: + if x is None or x == "": + return default + return float(str(x).replace(",", ".")) + except Exception: + return default + +def _to_int(x, default=None): + try: + if x is None or x == "": + return default + return int(float(str(x))) + except Exception: + return default + +# ------------------------------ +# Condition normalization +# ------------------------------ +# Map of keyword→condition_code (lowercased keywords) +_CONDITION_KEYWORDS = [ + # Order matters (more specific first) + ("funnel cloud", "tornado"), + ("tornado", "tornado"), + ("waterspout", "tornado"), + ("hurricane", "hurricane"), + ("cyclone", "cyclone"), + + ("thunderstorm", "thunderstorm"), + ("t-storm", "thunderstorm"), + ("tstorm", "thunderstorm"), + ("thundershower", "thundershowers"), + ("lightning", "lightning"), + + ("freezing rain", "freezing_rain"), + ("freezing drizzle", "freezing_drizzle"), + ("freezing spray", "freezing_rain"), + ("ice pellets", "ice_pellets"), + ("sleet", "sleet"), + ("hail", "hail"), + ("snow grains", "snow_grains"), + ("ice", "black_ice"), # generic ice reference (fallback) + + ("blowing snow", "blowing_snow"), + ("drifting snow", "blowing_snow"), + ("snow", "snow"), + ("flurries", "flurries"), + ("mixed precipitation", "mixed_rain_snow"), + ("rain and snow", "mixed_rain_snow"), + ("wet snow", "snow"), + ("graupel", "hail"), + + ("heavy rain", "heavy_rain"), + ("rain showers", "rain_showers"), + ("showers", "showers"), + ("rain", "rain"), + ("drizzle", "drizzle"), + + ("freezing fog", "freezing_fog"), + ("fog patches", "fog"), + ("dense fog", "fog"), + ("fog", "fog"), + ("mist", "mist"), + ("haze", "haze"), + + ("smoke", "smoke"), + ("ash", "ash"), + ("dust", "dust"), + + ("blowing dust", "dust"), + ("sand", "dust"), + + ("gale", "gale_warning"), + ("windy", "strong_wind"), + ("strong wind", "strong_wind"), + ("gust", "gusty"), + ("wind chill", "wind_chill"), + + ("overcast", "overcast"), + ("mainly cloudy", "mostly_cloudy"), + ("mostly cloudy", "mostly_cloudy"), + ("partly cloudy", "partly_cloudy"), + ("cloudy", "cloudy"), + ("mainly clear", "mostly_clear"), + ("mostly clear", "mostly_clear"), + ("clear", "clear"), + ("sunny", "sunny"), + ("fair", "clear"), +] + +# Night hint keywords +_NIGHT_HINTS = ("night", "this evening", "overnight") + +def _normalize_condition(raw_text: str | None, is_night: bool = False) -> tuple[str, str]: + """ + Convert EC condition text to a normalized (condition, condition_code). + Returns (pretty_condition, condition_code). + """ + if not raw_text: + return ("Unknown", "unknown") + + txt = raw_text.strip() + lo = txt.lower() + + # Keyword pass + for kw, code in _CONDITION_KEYWORDS: + if kw in lo: + # adjust clear/partly for night + if code in ("clear", "mostly_clear", "partly_cloudy") and is_night: + night_map = { + "clear": "night_clear", + "mostly_clear": "night_clear", + "partly_cloudy": "night_partly_cloudy" + } + return (txt, night_map.get(code, code)) + return (txt, code) + + # Fallbacks + if "cloud" in lo and is_night: + return (txt, "night_cloudy") + if "cloud" in lo: + return (txt, "cloudy") + if "sun" in lo: + return (txt, "sunny") + if "snow" in lo: + return (txt, "snow") + if "rain" in lo or "shower" in lo: + return (txt, "rain") + if "fog" in lo or "mist" in lo or "haze" in lo: + return (txt, "fog") + + return (txt, "unknown") + +# ------------------------------ +# XML parsing helpers +# ------------------------------ +def _find_text(root: ET.Element, path: str) -> str | None: + node = root.find(path) + return node.text if node is not None else None + +def _parse_last_updated(root: ET.Element) -> str | None: + """ + EC XML uses dateTime elements; we want the observation timestamp. + + 2025-11-09T19:05:00Z + ... + """ + for dt in root.findall(".//dateTime"): + if dt.get("name") == "observation": + ts = dt.findtext("timeStamp") + if ts: + try: + dt_utc = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(timezone.utc) + # Return local time string (HH:MM TZ) + local = dt_utc.astimezone() + return local.strftime("%H:%M %Z") + except Exception: + return ts + return None + +def _is_night(root: ET.Element) -> bool: + """ + Heuristic: if iconCode present and ends with 'n' or + 'period' name indicates night/evening. + """ + icon = _find_text(root, "currentConditions/iconCode") + if icon and icon.lower().endswith("n"): + return True + period = _find_text(root, "currentConditions/period") + if period and any(h in period.lower() for h in _NIGHT_HINTS): + return True + return False + +def _parse_weather_xml(root: ET.Element) -> dict: + """ + Parse EC XML current conditions → normalized dict. + """ + is_night = _is_night(root) + + raw_condition = _find_text(root, "currentConditions/condition") + pretty_cond, cond_code = _normalize_condition(raw_condition, is_night=is_night) + + # Extract values safely + temp = _to_float(_find_text(root, "currentConditions/temperature")) + feels = _to_float(_find_text(root, "currentConditions/feelsLike")) + wind_dir = _find_text(root, "currentConditions/wind/direction") + wind_spd = _to_int(_find_text(root, "currentConditions/wind/speed")) + rh = _to_int(_find_text(root, "currentConditions/relativeHumidity")) + pres = _to_float(_find_text(root, "currentConditions/pressure")) + vis = _to_float(_find_text(root, "currentConditions/visibility")) + updated = _parse_last_updated(root) + + # Location name may be richer than city; keep both when UI wants it + location_name = _find_text(root, "location/name") + + return { + "location": location_name, + "condition": pretty_cond or "Unknown", + "condition_code": cond_code, + "temperature_c": temp, + "feels_like": feels, + "wind_dir": wind_dir or "", + "wind_speed": wind_spd, + "humidity": rh, + "pressure_kpa": pres, + "visibility_km": vis, + "last_updated": updated or "—" + } + +# ------------------------------ +# Public API: current weather +# ------------------------------ +def fetch_weather(lat: float | None = None, lon: float | None = None) -> dict: + """ + Retrieve normalized current weather from EC citypage XML, + caching results for ~15 minutes. + """ + location = resolve_location() + province = location["province"] + # For now, nearest_station is a stub; when implemented, station_id will track actual nearest + station = nearest_station(location["latitude"], location["longitude"]) + station_id = station.get("station_id", "ON-40") + + url = f"{EC_WEATHER_BASE}/{province}/{station_id}_e.xml" + cache_age_minutes = 15 + + cached = _load_json(WEATHER_CACHE) + if cached: + try: + ts = datetime.fromisoformat(cached.get("timestamp", "1970-01-01T00:00:00")) + except Exception: + ts = datetime.min + if datetime.now() - ts < timedelta(minutes=cache_age_minutes): + return cached + + try: + resp = requests.get(url, timeout=12) + resp.raise_for_status() + root = ET.fromstring(resp.content) + data = _parse_weather_xml(root) + data["timestamp"] = datetime.now().isoformat(timespec="seconds") + _save_json(WEATHER_CACHE, data) + return data + except Exception as e: + print(f"[WARN] Weather fetch failed: {e}") + return cached or {"condition": "Unavailable", "condition_code": "unknown"} + +# ------------------------------ +# Alerts (Atom feed per province) +# ------------------------------ +_SEVERITY_ORDER = { + "statement": 1, # Special Weather Statement + "advisory": 2, + "watch": 3, + "warning": 4 +} + +def _grade_severity(title: str) -> tuple[int, str]: + """ + Infer severity from the alert title text. + """ + t = (title or "").lower() + if "warning" in t: + return (_SEVERITY_ORDER["warning"], "warning") + if "watch" in t: + return (_SEVERITY_ORDER["watch"], "watch") + if "advisory" in t: + return (_SEVERITY_ORDER["advisory"], "advisory") + if "special weather statement" in t or "statement" in t: + return (_SEVERITY_ORDER["statement"], "statement") + return (0, "info") + +def _parse_alert_atom(feed: str) -> list[dict]: + try: + root = ET.fromstring(feed) + except ET.ParseError: + return [] + + ns = {"a": "http://www.w3.org/2005/Atom"} + alerts = [] + for entry in root.findall("a:entry", ns): + title = entry.findtext("a:title", default="Alert", namespaces=ns) + summary = entry.findtext("a:summary", default="", namespaces=ns) + updated = entry.findtext("a:updated", default="", namespaces=ns) + link_el = entry.find("a:link", ns) + href = link_el.get("href") if link_el is not None else "" + rank, sev = _grade_severity(title) + + alerts.append({ + "title": title.strip(), + "description": (summary or "").strip(), + "updated": updated, + "url": href, + "severity": sev, + "rank": rank + }) + # Sort by severity (highest first) + alerts.sort(key=lambda x: x["rank"], reverse=True) + return alerts + +def fetch_alerts(lat: float | None = None, lon: float | None = None) -> list[dict]: + """ + Fetch active alerts for the province; cache ~10 minutes. + """ + location = resolve_location() + province = location["province"] + + url = f"{EC_ALERTS_BASE}/{province}.atom" + cache_age_minutes = 10 + + cached = _load_json(ALERT_CACHE) + if cached: + try: + ts = datetime.fromisoformat(cached.get("timestamp", "1970-01-01T00:00:00")) + except Exception: + ts = datetime.min + if datetime.now() - ts < timedelta(minutes=cache_age_minutes): + return cached.get("alerts", []) + + try: + resp = requests.get(url, timeout=12) + resp.raise_for_status() + alerts = _parse_alert_atom(resp.text) + payload = {"alerts": alerts, "timestamp": datetime.now().isoformat(timespec="seconds")} + _save_json(ALERT_CACHE, payload) + return alerts + except Exception as e: + print(f"[WARN] Alert fetch failed: {e}") + return cached.get("alerts", []) if cached else [] diff --git a/src/localization/__init__.py b/src/localization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/localization/en_CA.json b/src/localization/en_CA.json new file mode 100644 index 0000000..8aafd3d --- /dev/null +++ b/src/localization/en_CA.json @@ -0,0 +1,62 @@ +{ + "language": "English (Canada)", + "labels": { + "city": "City", + "province": "Province", + "updated": "Updated", + "temperature": "Temperature", + "feels_like": "Feels Like", + "condition": "Condition", + "wind": "Wind", + "wind_speed": "Wind Speed", + "wind_direction": "Wind Direction", + "humidity": "Humidity", + "pressure": "Pressure", + "visibility": "Visibility", + "uv_index": "UV Index", + "dew_point": "Dew Point", + "station": "Station", + "alerts": "Alerts", + "no_alerts": "No active alerts", + "alert_title": "Weather Alert", + "alert_description": "Description", + "alert_issued": "Issued By", + "alert_effective": "Effective", + "alert_expires": "Expires", + "severity": "Severity", + "advisory": "Advisory", + "watch": "Watch", + "warning": "Warning", + "statement": "Special Weather Statement", + "air_quality": "Air Quality", + "wind_chill": "Wind Chill", + "heat_index": "Heat Index", + "precipitation": "Precipitation", + "rainfall": "Rainfall", + "snowfall": "Snowfall", + "forecast": "Forecast", + "current_conditions": "Current Conditions", + "direction": { + "N": "North", + "S": "South", + "E": "East", + "W": "West", + "NE": "Northeast", + "NW": "Northwest", + "SE": "Southeast", + "SW": "Southwest" + }, + "units": { + "temperature": "°C", + "speed": "km/h", + "pressure": "kPa", + "distance": "km" + }, + "misc": { + "loading": "Loading...", + "offline": "Offline Mode", + "unknown": "Unknown", + "method": "Data Source" + } + } +} diff --git a/src/localization/fr_CA.json b/src/localization/fr_CA.json new file mode 100644 index 0000000..ef936c9 --- /dev/null +++ b/src/localization/fr_CA.json @@ -0,0 +1,62 @@ +{ + "language": "Français (Canada)", + "labels": { + "city": "Ville", + "province": "Province", + "updated": "Mis à jour", + "temperature": "Température", + "feels_like": "Température ressentie", + "condition": "Condition", + "wind": "Vent", + "wind_speed": "Vitesse du vent", + "wind_direction": "Direction du vent", + "humidity": "Humidité", + "pressure": "Pression", + "visibility": "Visibilité", + "uv_index": "Indice UV", + "dew_point": "Point de rosée", + "station": "Station", + "alerts": "Alertes", + "no_alerts": "Aucune alerte active", + "alert_title": "Alerte météo", + "alert_description": "Description", + "alert_issued": "Émis par", + "alert_effective": "En vigueur", + "alert_expires": "Expire", + "severity": "Gravité", + "advisory": "Avis", + "watch": "Veille", + "warning": "Avertissement", + "statement": "Bulletin spécial sur les conditions météorologiques", + "air_quality": "Qualité de l'air", + "wind_chill": "Refroidissement éolien", + "heat_index": "Indice de chaleur", + "precipitation": "Précipitations", + "rainfall": "Pluie", + "snowfall": "Neige", + "forecast": "Prévisions", + "current_conditions": "Conditions actuelles", + "direction": { + "N": "Nord", + "S": "Sud", + "E": "Est", + "W": "Ouest", + "NE": "Nord-est", + "NW": "Nord-ouest", + "SE": "Sud-est", + "SW": "Sud-ouest" + }, + "units": { + "temperature": "°C", + "speed": "km/h", + "pressure": "kPa", + "distance": "km" + }, + "misc": { + "loading": "Chargement...", + "offline": "Mode hors ligne", + "unknown": "Inconnu", + "method": "Source des données" + } + } +} diff --git a/src/localization/iu_CA.json b/src/localization/iu_CA.json new file mode 100644 index 0000000..b6fa7f4 --- /dev/null +++ b/src/localization/iu_CA.json @@ -0,0 +1,62 @@ +{ + "language": "Inuktut (ᐃᓄᒃᑎᑐᑦ)", + "labels": { + "city": "ᐃᓕᖅᓯᒪᔪᑦ / City", + "province": "ᐱᕈᖅᑕᖅ / Region", + "updated": "ᐅᖃᐅᓯᖅᑐᖅ / Updated", + "temperature": "ᐊᑭᐅᔭᖅᑎᑦᑎ / Temperature", + "feels_like": "ᐊᓯᐅᕗᑦ ᐊᑭᐅᔭᖅᑎᑦᑎ / Feels Like", + "condition": "ᐊᐅᓚᔭᖅ / Condition", + "wind": "ᐅᓐᖏᕐᓂᖅ / Wind", + "wind_speed": "ᐅᓐᖏᕐᓂᖅ ᐅᓂᒃᑳᓕᒃ / Wind Speed", + "wind_direction": "ᐅᓐᖏᕐᓂᖅ ᐊᓯᓇᓂ / Direction", + "humidity": "ᐱᓇᓱᑎᑦᑎ / Humidity", + "pressure": "ᐊᓯᑎᓕᒃᑯᑦ ᐊᓯᓂᖅ / Pressure", + "visibility": "ᐱᔭᐅᔪᖅ / Visibility", + "uv_index": "UV ᐅᓂᒃᑳᓕᒃ / UV Index", + "dew_point": "ᐊᑭᐅᔭᖅᑎᑦᑎ ᓂᑯᓂᖅ / Dew Point", + "station": "ᑭᒡᓕᒐᖅᑎᓪᓗᒍ / Station", + "alerts": "ᐊᔭᕙᑎᑦᑎ / Alerts", + "no_alerts": "ᐱᔾᔪᑎᑕᐅᓯᒪᖅᑐᖅ ᐊᔭᕙᑎᑦᑎ / No Alerts", + "alert_title": "ᐊᔭᕙᑎᑦᑎ / Weather Alert", + "alert_description": "ᐅᖃᓕᒫᓂᖅ / Description", + "alert_issued": "ᐊᓂᖅᐸᑦᑎᓂᖅ / Issued By", + "alert_effective": "ᐅᓇᒃᑯᑦ / Effective", + "alert_expires": "ᐊᓐᓄᓴᖅ / Expires", + "severity": "ᐊᒥᓱᓂᖅ / Severity", + "advisory": "ᐅᔾᔨᖅᑎᑕᐅᑎ / Advisory", + "watch": "ᓄᓇᕗᖅᑎᑦᑎ / Watch", + "warning": "ᐊᔭᕙᑎᑦᑎ / Warning", + "statement": "ᐊᖏᕐᓂᖅᑎᑦᑎ / Statement", + "air_quality": "ᐊᕐᓂᖅ ᐱᓇᓱᑎᑦᑎ / Air Quality", + "wind_chill": "ᐅᓐᖏᕐᓂᖅ ᐊᓯᐅᕗᑦ / Wind Chill", + "heat_index": "ᐊᑭᐅᔭᖅᑎᑦᑎ ᐅᓂᒃᑳᓕᒃ / Heat Index", + "precipitation": "ᐊᓂᕆᔭᖅ / Precipitation", + "rainfall": "ᐊᓂᕆᔭᖅ / Rainfall", + "snowfall": "ᐊᐳᑦᑎᓪᓗᒍ / Snowfall", + "forecast": "ᐊᓯᓇᐅᑎᑦᑎ / Forecast", + "current_conditions": "ᐅᖃᓕᒫᓂᖅ / Current Conditions", + "direction": { + "N": "ᓄᓇᕗᖅ / North", + "S": "ᓯᑎᕗᖅ / South", + "E": "ᐊᓗᕐᓂᖅ / East", + "W": "ᐅᒥᐊᑦ / West", + "NE": "ᓄᓇᕗᖅ-ᐊᓗᕐᓂᖅ / Northeast", + "NW": "ᓄᓇᕗᖅ-ᐅᒥᐊᑦ / Northwest", + "SE": "ᓯᑎᕗᖅ-ᐊᓗᕐᓂᖅ / Southeast", + "SW": "ᓯᑎᕗᖅ-ᐅᒥᐊᑦ / Southwest" + }, + "units": { + "temperature": "°C", + "speed": "km/h", + "pressure": "kPa", + "distance": "km" + }, + "misc": { + "loading": "ᐅᖃᐅᓯᖅᑐᖅ / Loading", + "offline": "ᐃᓗᐃᑦ / Offline", + "unknown": "ᐱᔾᔪᑎᑕᐅᓯᒪᖅᑐᖅ / Unknown", + "method": "ᐊᑐᐱᓂᖅ / Source" + } + } +} diff --git a/src/localization/localization_manager.py b/src/localization/localization_manager.py new file mode 100644 index 0000000..4123c6d --- /dev/null +++ b/src/localization/localization_manager.py @@ -0,0 +1,103 @@ +""" +File: src/localization/localization_manager.py +---------------------------------------------- +Skyfeed Localization Manager +Loads and manages language data for Skyfeed. + +Features: + - Auto-detects system locale (en_CA, fr_CA, iu_CA) + - Reads override language from config/config.json + - Graceful fallback to English if translation missing + - Provides get_label(category, key) for unified access +""" + +import os +import json +import locale + +LOCALIZATION_PATH = os.path.expanduser("~/Projects/Skyfeed/src/localization/") +DEFAULT_LANG = "en_CA" + + +class LocalizationManager: + def __init__(self, config_language: str | None = None): + """ + Initialize localization system. + If config_language is set, use that. + Otherwise, try system locale detection. + """ + self.language_code = config_language or self._detect_locale() + self.language_data = self._load_language_file(self.language_code) + self._fallback_data = self._load_language_file(DEFAULT_LANG) + + # ------------------------------------------------------------ + # Locale detection + # ------------------------------------------------------------ + def _detect_locale(self) -> str: + """Detect system locale (e.g., 'fr_CA', 'iu_CA') with fallback.""" + try: + lang, _ = locale.getdefaultlocale() + if not lang: + return DEFAULT_LANG + if "fr" in lang: + return "fr_CA" + elif "iu" in lang or "ike" in lang: + return "iu_CA" + return DEFAULT_LANG + except Exception: + return DEFAULT_LANG + + # ------------------------------------------------------------ + # File loading + # ------------------------------------------------------------ + def _load_language_file(self, code: str) -> dict: + """Load a language JSON file.""" + file_path = os.path.join(LOCALIZATION_PATH, f"{code}.json") + if not os.path.exists(file_path): + print(f"[WARN] Missing language file: {file_path}, using fallback.") + return {} + try: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f).get("labels", {}) + except Exception as e: + print(f"[WARN] Failed to load language {code}: {e}") + return {} + + # ------------------------------------------------------------ + # Lookup + # ------------------------------------------------------------ + def get_label(self, *path: str) -> str: + """ + Retrieve a label via key path, e.g.: + get_label("direction", "NW") → "Northwest" + Falls back to English if key missing. + """ + node = self.language_data + for key in path: + if isinstance(node, dict) and key in node: + node = node[key] + else: + node = None + break + + if node is None: + # fallback + node = self._fallback_data + for key in path: + if isinstance(node, dict) and key in node: + node = node[key] + else: + node = "?" + break + return node if isinstance(node, str) else "?" + + # ------------------------------------------------------------ + # Info + # ------------------------------------------------------------ + def get_language_name(self) -> str: + """Return the readable name of the active language.""" + return { + "en_CA": "English (Canada)", + "fr_CA": "Français (Canada)", + "iu_CA": "Inuktut (ᐃᓄᒃᑎᑐᑦ)" + }.get(self.language_code, "English (Canada)") diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/__pycache__/__init__.cpython-313.pyc b/src/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..df04219 Binary files /dev/null and b/src/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/ui/__pycache__/app.cpython-313.pyc b/src/ui/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..beb006e Binary files /dev/null and b/src/ui/__pycache__/app.cpython-313.pyc differ diff --git a/src/ui/__pycache__/renderer.cpython-313.pyc b/src/ui/__pycache__/renderer.cpython-313.pyc new file mode 100644 index 0000000..9e25e33 Binary files /dev/null and b/src/ui/__pycache__/renderer.cpython-313.pyc differ diff --git a/src/ui/__pycache__/theme.cpython-313.pyc b/src/ui/__pycache__/theme.cpython-313.pyc new file mode 100644 index 0000000..1e12d7f Binary files /dev/null and b/src/ui/__pycache__/theme.cpython-313.pyc differ diff --git a/src/ui/app.py b/src/ui/app.py new file mode 100644 index 0000000..3cc16b0 --- /dev/null +++ b/src/ui/app.py @@ -0,0 +1,23 @@ +""" +Skyfeed App +----------- +Launches graphical terminal-style UI using Tkinter. +""" + +import tkinter as tk +from src.core import cache_manager +from src.ui.renderer import draw_main_frame + + +def launch(): + root = tk.Tk() + root.title("Skyfeed") + root.configure(bg="black") + root.geometry("900x500") + + data = cache_manager.read_weather() + alerts = cache_manager.read_alerts() + + draw_main_frame(root, data, alerts) + + root.mainloop() diff --git a/src/ui/fullscreen_view.py b/src/ui/fullscreen_view.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/layout_manager.py b/src/ui/layout_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/minimal_view.py b/src/ui/minimal_view.py new file mode 100644 index 0000000..2b293a0 --- /dev/null +++ b/src/ui/minimal_view.py @@ -0,0 +1,173 @@ +""" +File: src/ui/minimal_view.py +---------------------------- +Skyfeed Minimal View (Unicode Terminal Mode) +Displays current weather in a left-right layout: + - Left column: text information + - Right column: large Unicode weather icon + +If an alert or advisory is active, a red border appears around the frame. +""" + +import tkinter as tk +from datetime import datetime +import threading +import time + +from src.core.location_manager import resolve_location +from src.core.weather_client import fetch_weather, fetch_alerts +from src.utils.weather_symbols import get_icon +from src.ui.theme import get_color + +ICON_SCALE = 4 # Adjust this (2, 3, or 4) to change icon size multiplier + + +class MinimalView: + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Skyfeed") + self.root.configure(bg=get_color("bg")) + + # Outer frame (for alert border) + self.frame = tk.Frame(root, bg=get_color("bg"), bd=4, relief="solid") + self.frame.pack(expand=True, fill="both", padx=10, pady=10) + + # Fonts + self.font_name = "Modeseven" + self.font_size = 13 + + # Left text section + self.text_frame = tk.Frame(self.frame, bg=get_color("bg")) + self.text_frame.pack(side="left", anchor="nw", fill="y", padx=10, pady=10) + + # Weather icon section (right) + self.icon_label = tk.Label( + self.frame, + bg=get_color("bg"), + fg=get_color("sky_blue"), + font=(self.font_name, self.font_size * ICON_SCALE), + text="", + justify="center", + ) + self.icon_label.pack(side="right", anchor="ne", padx=20, pady=10) + + # Text labels + self.header_label = tk.Label( + self.text_frame, + bg=get_color("bg"), + fg=get_color("sun_yellow"), + font=(self.font_name, self.font_size), + justify="left", + text="", + ) + self.header_label.pack(anchor="w") + + self.details_label = tk.Label( + self.text_frame, + bg=get_color("bg"), + fg=get_color("fg_text"), + font=(self.font_name, self.font_size), + justify="left", + text="", + ) + self.details_label.pack(anchor="w") + + self.alert_label = tk.Label( + self.text_frame, + bg=get_color("bg"), + fg=get_color("alert_red"), + font=(self.font_name, self.font_size), + justify="left", + wraplength=400, + text="", + ) + self.alert_label.pack(anchor="w", pady=(5, 0)) + + # Data storage + self.location = resolve_location() + self.weather = None + self.alerts = [] + self.last_update_time = None + + self.refresh_weather() + self.start_clock() + + # ------------------------------------------------------------ + # Weather refresh + # ------------------------------------------------------------ + def refresh_weather(self): + lat, lon = self.location["lat"], self.location["lon"] + self.weather = fetch_weather(lat, lon) + self.alerts = fetch_alerts(lat, lon) + self.last_update_time = datetime.now().strftime("%H:%M %Z") + self.render() + self.root.after(15 * 60 * 1000, self.refresh_weather) + + # ------------------------------------------------------------ + # Clock + # ------------------------------------------------------------ + def start_clock(self): + def tick(): + while True: + self.render_clock() + time.sleep(1) + + threading.Thread(target=tick, daemon=True).start() + + def render_clock(self): + now = datetime.now().strftime("%H:%M:%S") + self.root.title(f"Skyfeed — {now}") + + # ------------------------------------------------------------ + # Render + # ------------------------------------------------------------ + def render(self): + w = self.weather or {} + a = self.alerts[0] if self.alerts else None + + icon = get_icon(w.get("condition_code", "unknown")) + city = self.location.get("city", "Unknown") + prov = self.location.get("province", "") + updated = self.weather.get("last_updated", "—") + + # Header + header = f"{city}, {prov}\nUpdated: {updated}" + self.header_label.config(text=header) + + # Weather details + temp = f"{w.get('temperature_c', '--')}°C" + feels = f"{w.get('feels_like', '--')}°C" + wind = f"{w.get('wind_dir', '')} {w.get('wind_speed', 0)} km/h" + rh = f"{w.get('humidity', 0)}%" + press = f"{w.get('pressure_kpa', 0)} kPa" + vis = f"{w.get('visibility_km', 0)} km" + cond = w.get("condition", "Unknown") + + details = ( + f"{temp} {cond}\n" + f"Feels: {feels} Wind: {wind} RH: {rh}\n" + f"Pressure: {press} Visibility: {vis}" + ) + self.details_label.config(text=details) + + # Weather icon + self.icon_label.config(text=icon) + + # Alert handling + if a: + title = a.get("title", "Alert") + desc = a.get("description", "").strip().replace("\n", " ") + self.alert_label.config(text=f"⚠ {title}\n{desc}") + self.frame.config(highlightbackground=get_color("alert_red"), highlightthickness=4) + else: + self.alert_label.config(text="") + self.frame.config(highlightbackground=get_color("bg"), highlightthickness=4) + + +# ------------------------------------------------------------ +# Standalone +# ------------------------------------------------------------ +if __name__ == "__main__": + root = tk.Tk() + app = MinimalView(root) + root.mainloop() diff --git a/src/ui/renderer.py b/src/ui/renderer.py new file mode 100644 index 0000000..619e739 --- /dev/null +++ b/src/ui/renderer.py @@ -0,0 +1,47 @@ +""" +Renderer +-------- +Draws Skyfeed’s terminal-style display. +""" + +import tkinter as tk +from src.ui.theme import THEMES + +def draw_main_frame(root, data, alerts): + theme = THEMES["skyfeed_default"] + + frame = tk.Frame(root, bg=theme["bg"]) + frame.pack(fill="both", expand=True) + + text = tk.Text( + frame, + bg=theme["bg"], + fg=theme["fg_text"], + insertbackground=theme["fg_primary"], + font=("DejaVu Sans Mono", 14), + relief="flat" + ) + text.pack(fill="both", expand=True) + + if not data: + text.insert("end", "No weather data available.\nRun 'skyfeed fetch' first.") + return + + text.insert("end", f"{data['location']} {data['timestamp']}\n") + text.insert("end", "─" * 50 + "\n") + text.insert( + "end", + f"☀ {data['temperature_c']}°C {data['condition']}\n" + f"Feels: {data['feels_like_c']}°C " + f"Wind: {data['wind_dir']} {data['wind_kph']} km/h " + f"RH: {data['humidity']}%\n" + f"Pressure: {data['pressure_kpa']} kPa " + f"Visibility: {data['visibility_km']} km\n" + ) + + if alerts: + text.insert("end", "─" * 50 + "\n") + for alert in alerts: + text.insert("end", f"⚠ {alert['title']}\n{alert['description']}\n") + + text.config(state="disabled") diff --git a/src/ui/theme.py b/src/ui/theme.py new file mode 100644 index 0000000..a363bf2 --- /dev/null +++ b/src/ui/theme.py @@ -0,0 +1,54 @@ +""" +Skyfeed UI Theme +---------------- +Unified colour palette inspired by MS-DOS, Teletext, and CRT terminals. +This palette ensures Skyfeed remains readable, authentic, and "terminal-safe" +across both CLI and graphical UI environments. + +Each colour is chosen to work well in monospaced text UIs, ensuring high +contrast on a pure black background. +""" + +THEME = { + # --- Core background / text --- + "bg": "#000000", # pure black background + "fg_text": "#E0E0E0", # light neutral text (soft white) + "border": "#202020", # low-contrast divider or outline + + # --- Primary palette (MS-DOS safe) --- + "black": "#000000", + "blue": "#0000AA", + "green": "#00AA00", + "cyan": "#00AAAA", + "red": "#AA0000", + "magenta": "#AA00AA", + "yellow": "#AAAA00", + "white": "#AAAAAA", + + # --- Bright variants --- + "bright_blue": "#5555FF", + "bright_green": "#55FF55", + "bright_cyan": "#55FFFF", + "bright_red": "#FF5555", + "bright_magenta": "#FF55FF", + "bright_yellow": "#FFFF55", + "bright_white": "#FFFFFF", + + # --- Skyfeed custom accents --- + "sky_blue": "#00BFFF", # Skyfeed identity blue + "sun_yellow": "#FFD300", # sunny yellow accent + "frost_blue": "#A8EFFF", # freezing conditions + "night_violet": "#7059FF",# night conditions + "heat_orange": "#FF7033", # heat warning + "alert_red": "#FF4040", # severe alert + "advisory_orange": "#FFA500", # advisory / caution + "ok_green": "#00FF00", # positive / normal +} + + +def get_color(role: str) -> str: + """ + Retrieve a colour from the unified palette. + If the role doesn't exist, returns bright white as default. + """ + return THEME.get(role, THEME["bright_white"]) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/formatter.py b/src/utils/formatter.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/time_utils.py b/src/utils/time_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/weather_symbols.py b/src/utils/weather_symbols.py new file mode 100644 index 0000000..2efaa36 --- /dev/null +++ b/src/utils/weather_symbols.py @@ -0,0 +1,178 @@ +""" +File: src/utils/weather_symbols.py +---------------------------------- +Skyfeed – Complete Unicode weather & hazard icon map. + +Covers all Environment Canada condition strings and advisories: +• Weather (sun, rain, snow, fog, etc.) +• Hazards (floods, black ice, wind chill) +• Special statements (travel, air quality, visibility) +• Tropical and severe phenomena (hurricanes, tornadoes, thunderstorms) +• Night variants for clear/partly cloudy/overcast conditions +""" + +WEATHER_ICONS = { + # --- Clear / Cloudy / Night Variants --- + "clear": "☀", + "sunny": "☀", + "mostly_clear": "🌤", + "mainly_clear": "🌤", + "partly_cloudy": "⛅", + "mostly_cloudy": "🌥", + "cloudy": "☁", + "overcast": "☁", + "night_clear": "🌙", + "night_partly_cloudy": "🌃", + "night_cloudy": "☁", + + # --- Rain / Showers / Drizzle --- + "rain": "🌧", + "light_rain": "🌦", + "moderate_rain": "🌧", + "heavy_rain": "🌧", + "rain_heavy": "🌧", + "rain_showers": "🌦", + "showers": "🌦", + "drizzle": "💧", + "freezing_rain": "🧊", + "freezing_drizzle": "🧊", + "freezing_spray": "🧊", + "mixed_rain_snow": "🌧❄", + "mixed_precipitation": "🌧❄", + + # --- Snow / Ice / Wintry Mix --- + "snow": "❄", + "light_snow": "🌨", + "moderate_snow": "❄", + "heavy_snow": "❅", + "blowing_snow": "🌬", + "drifting_snow": "🌬", + "flurries": "❄", + "snow_grains": "❆", + "snow_pellets": "❄", + "graupel": "🧊", + "ice_pellets": "🧊", + "sleet": "🌨", + "hail": "🧊", + "black_ice": "⚫", + "frost": "❄", + + # --- Thunder / Severe Storms --- + "thunderstorm": "⛈", + "thundershowers": "⛈", + "lightning": "⚡", + "tstorm": "⛈", + "storm": "⛈", + "funnel_cloud": "🌪", + "waterspout": "🌪", + "tornado": "🌪", + + # --- Wind / Gale / Tropical Systems --- + "wind": "🌬", + "strong_wind": "💨", + "gusty": "💨", + "gale": "💨", + "gale_warning": "💨", + "wind_chill": "🥶", + "hurricane": "🌀", + "cyclone": "🌀", + "typhoon": "🌀", + "tropical_storm": "🌀", + + # --- Fog / Visibility / Air Quality --- + "fog": "🌫", + "dense_fog": "🌫", + "freezing_fog": "🌫", + "mist": "🌫", + "haze": "〰", + "smoke": "💨", + "ash": "🌋", + "dust": "💨", + "blowing_dust": "💨", + "smog": "🌫", + "low_visibility": "👁‍🗨", + "poor_visibility": "👁‍🗨", + "air_quality": "🌫", + "air_quality_alert": "🚭", + "air_pollution": "🚭", + + # --- Temperature Extremes --- + "cold": "❄", + "extreme_cold": "🥶", + "hot": "🌡", + "heat": "🌡", + "heat_wave": "🔥", + "heat_warning": "🔥", + "extreme_heat": "🔥", + + # --- Hydrological / Flood / Rainfall Warnings --- + "flood": "🌊", + "flash_flood": "🌊", + "flood_warning": "🌊", + "rainfall_warning": "🌧", + "storm_surge": "🌊", + "tsunami": "🌊", + "rip_current": "🌊", + + # --- Travel / Ice / Road Conditions --- + "travel_advisory": "🚗", + "black_ice_warning": "⚫", + "slippery_roads": "🚗", + "road_closure": "⛔", + "freezing_rain_warning": "🧊", + + # --- Fire / Environmental / Air Quality --- + "wildfire": "🔥", + "forest_fire": "🔥", + "fire_weather": "🔥", + + # --- Marine / Coastal --- + "marine_warning": "⚓", + "wave": "🌊", + + # --- Advisory / Alert / Statement Levels --- + "alert": "⚠", + "advisory": "⚡", + "watch": "👁", + "warning": "⚠", + "special_weather_statement": "ℹ", + "statement": "ℹ", + "hazardous_weather": "☣", + "danger": "☠", + "ok": "✓", + + # --- Fallback --- + "unknown": "◌" +} + + +def get_icon(condition_code: str) -> str: + """ + Return the best matching Unicode symbol for a given condition or advisory text. + Performs fuzzy keyword matching so even complex phrases are covered. + + Examples: + get_icon("snow") → "❄" + get_icon("Rainfall Warning") → "⚠" + get_icon("Extreme Cold Advisory") → "⚡" + """ + if not condition_code: + return WEATHER_ICONS["unknown"] + + code = condition_code.lower().replace(" ", "_") + + # Exact match + if code in WEATHER_ICONS: + return WEATHER_ICONS[code] + + # Severity fallbacks + for severity in ("warning", "watch", "advisory", "statement"): + if severity in code: + return WEATHER_ICONS[severity] + + # Keyword fallback + for key in WEATHER_ICONS.keys(): + if key in code: + return WEATHER_ICONS[key] + + return WEATHER_ICONS["unknown"] diff --git a/tests/test_alerts.py b/tests/test_alerts.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_location.py b/tests/test_location.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ui_fullscreen.py b/tests/test_ui_fullscreen.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ui_minimal.py b/tests/test_ui_minimal.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..e69de29