From e982d4d6aaf6ffd37098ab773650593da0c2b4f6 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Sun, 9 Nov 2025 14:30:21 -0500 Subject: [PATCH] Skyfeed v0.1.0.0 - initial scaffolding, docs, and architecture --- .gitignore | 0 LICENSE | 0 assets/fonts/Modeseven.ttf | Bin 0 -> 28360 bytes cache/alert.json | 9 + cache/meta.json | 0 cache/weather.json | 13 + config/config.json | 35 ++ docs/API/cli.md | 0 docs/API/core.md | 0 docs/API/localization.md | 0 docs/API/ui.md | 119 ++++++ docs/API/utils.md | 0 docs/ARCHITECTURE.md | 0 docs/CHANGELOG.md | 7 + docs/CONTRIBUTING.md | 0 docs/DESIGN/layout_spec.md | 0 docs/DESIGN/theme_reference.md | 0 docs/DESIGN/visual_style.md | 186 +++++++++ docs/LOCALIZATION.md | 0 docs/README.md | 0 docs/ROADMAP.md | 0 pyproject.toml | 1 + src/__init__.py | 0 src/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 139 bytes src/cli/__init__.py | 0 src/cli/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 143 bytes src/cli/__pycache__/main.cpython-313.pyc | Bin 0 -> 2472 bytes src/cli/main.py | 55 +++ src/config/config_manager.py | 141 +++++++ src/core/__init__.py | 0 src/core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 144 bytes .../__pycache__/alert_parser.cpython-313.pyc | Bin 0 -> 598 bytes .../__pycache__/cache_manager.cpython-313.pyc | Bin 0 -> 1811 bytes .../weather_client.cpython-313.pyc | Bin 0 -> 666 bytes src/core/alert_parser.py | 17 + src/core/cache_manager.py | 30 ++ src/core/location_manager.py | 122 ++++++ src/core/weather_client.py | 375 ++++++++++++++++++ src/localization/__init__.py | 0 src/localization/en_CA.json | 62 +++ src/localization/fr_CA.json | 62 +++ src/localization/iu_CA.json | 62 +++ src/localization/localization_manager.py | 103 +++++ src/ui/__init__.py | 0 src/ui/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 142 bytes src/ui/__pycache__/app.cpython-313.pyc | Bin 0 -> 971 bytes src/ui/__pycache__/renderer.cpython-313.pyc | Bin 0 -> 2190 bytes src/ui/__pycache__/theme.cpython-313.pyc | Bin 0 -> 1732 bytes src/ui/app.py | 23 ++ src/ui/fullscreen_view.py | 0 src/ui/layout_manager.py | 0 src/ui/minimal_view.py | 173 ++++++++ src/ui/renderer.py | 47 +++ src/ui/theme.py | 54 +++ src/utils/__init__.py | 0 src/utils/formatter.py | 0 src/utils/logger.py | 0 src/utils/time_utils.py | 0 src/utils/weather_symbols.py | 178 +++++++++ tests/test_alerts.py | 0 tests/test_location.py | 0 tests/test_ui_fullscreen.py | 0 tests/test_ui_minimal.py | 0 tests/test_weather.py | 0 64 files changed, 1874 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100755 assets/fonts/Modeseven.ttf create mode 100644 cache/alert.json create mode 100644 cache/meta.json create mode 100644 cache/weather.json create mode 100644 config/config.json create mode 100644 docs/API/cli.md create mode 100644 docs/API/core.md create mode 100644 docs/API/localization.md create mode 100644 docs/API/ui.md create mode 100644 docs/API/utils.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CHANGELOG.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/DESIGN/layout_spec.md create mode 100644 docs/DESIGN/theme_reference.md create mode 100644 docs/DESIGN/visual_style.md create mode 100644 docs/LOCALIZATION.md create mode 100644 docs/README.md create mode 100644 docs/ROADMAP.md create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-313.pyc create mode 100644 src/cli/__init__.py create mode 100644 src/cli/__pycache__/__init__.cpython-313.pyc create mode 100644 src/cli/__pycache__/main.cpython-313.pyc create mode 100644 src/cli/main.py create mode 100644 src/config/config_manager.py create mode 100644 src/core/__init__.py create mode 100644 src/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/core/__pycache__/alert_parser.cpython-313.pyc create mode 100644 src/core/__pycache__/cache_manager.cpython-313.pyc create mode 100644 src/core/__pycache__/weather_client.cpython-313.pyc create mode 100644 src/core/alert_parser.py create mode 100644 src/core/cache_manager.py create mode 100644 src/core/location_manager.py create mode 100644 src/core/weather_client.py create mode 100644 src/localization/__init__.py create mode 100644 src/localization/en_CA.json create mode 100644 src/localization/fr_CA.json create mode 100644 src/localization/iu_CA.json create mode 100644 src/localization/localization_manager.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/__pycache__/__init__.cpython-313.pyc create mode 100644 src/ui/__pycache__/app.cpython-313.pyc create mode 100644 src/ui/__pycache__/renderer.cpython-313.pyc create mode 100644 src/ui/__pycache__/theme.cpython-313.pyc create mode 100644 src/ui/app.py create mode 100644 src/ui/fullscreen_view.py create mode 100644 src/ui/layout_manager.py create mode 100644 src/ui/minimal_view.py create mode 100644 src/ui/renderer.py create mode 100644 src/ui/theme.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/formatter.py create mode 100644 src/utils/logger.py create mode 100644 src/utils/time_utils.py create mode 100644 src/utils/weather_symbols.py create mode 100644 tests/test_alerts.py create mode 100644 tests/test_location.py create mode 100644 tests/test_ui_fullscreen.py create mode 100644 tests/test_ui_minimal.py create mode 100644 tests/test_weather.py 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 0000000000000000000000000000000000000000..87c9280f92f929675fe70e233a1bd4e4698b1711 GIT binary patch literal 28360 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1cb0b9d1a31f12e1cd4b745330d991227554cc4d GIT binary patch literal 139 zcmey&%ge<81WvmIGC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iBenx(7s(x`v zseV9FepYI7NwI!#c4b;>YKnewQL=t~d}dx|NqoFsLFFwDo80`A(wtPgB37VQkkQ2; O#z$sGM#ds$APWGgMjr71 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9825c31cb60febf6bd0f6c2e6c4cc72523dd98e7 GIT binary patch literal 143 zcmey&%ge<81P^u!WP<3&AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkly%)D->VqGbK#oJ{@r_{_Y_lK6PNg34PQHo5sJr8%i~MXW&W SAmfWcjE~HWjEqIhKo$U!0U-GR literal 0 HcmV?d00001 diff --git a/src/cli/__pycache__/main.cpython-313.pyc b/src/cli/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cab50f8cd0001f82365bbacd1c0dad7808dbb1cb GIT binary patch literal 2472 zcmb_d-E$LF6u+D7CYwz*A3zxj{kWuvq_oXAiU{K<(psTl#q4DKD7vnj+jKSAWbSS# z&G1kj9FWnWFU+7%eMF!96Z{jB1`J+?|6wT4L}ma$e}GIXs1i)Y~x8{w*9YL!XLUa3%DhFPjD=cM0R(dKhu zlJpW&^@>6BBnc-MwXR_cVIu3AzJyd-F?v^=<49Pm*LAPp4xvo|Vft(r==&B}c&sb< z4Sy^FAnUaJ1ZRRGCe}3%h(SpnaD7!X95<*z0ov&4Ob1 z7@cdjAn3E*=gRuu)RynFJ>ct( zKgqe@1kE$+-=%QK=K>ns??1-{g=YglKIhLN1vHS`J>MMhfk*GhE(%%*FKRlXUuC?6 zEFBwl+o+i&>X}hFPvtfd9AIkxDK3Nw^wX69=?*ts#L8a$Q%`t>>_f^FCpL^ugEsLAaiqK3;3 zJdxyUxZJ2x)VkoJRZ=UJRIBl^3tkqH*xo}j$)PGeYhm{7V+LA8c1ojqAdOj2l5k~i zBA5*sk(aSyBh`aSl5n@K1rh3)_JV}16^robvSE`rMp{Yr)RBgvQqUxkiUZF?a?Dj$ z=v;(Ey!65}ZNjtcXw&Hb1mG{b>x3He4 zD4wrshB;MVAv|L+8?fEZylUoop19q_dUT&t?UdFyzUELXpOwe1gAFRnQnZj9Utn%M^SIjxP z?1q?KfAOw3-V^0k^5|W0q9?|0iZ{fy%$M=an0#~S^P$`OI9-I|*D{&Z{a8$YDaJPL#T@VA}L z_jF>zJu!Jxz9D~+>I=RV0kKq99Bhk&_rwF+w4{KDD*ZParmE%=B&w>Vny#uiLp7Ko z8xd(srt}&{&a^q> 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 0000000000000000000000000000000000000000..4ad38f1a2adc22c42a81b56f2f85f269b05d3518 GIT binary patch literal 144 zcmey&%ge<81dDeHWP<3&AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl=G{fzwFRQ=+T zQvHCU{H)aEl4AYf?8>y%)D->VqGbK#{GwF-`1s7c%#!$cy@JYH95%W6DWy57c15f} T{UGy;L5z>gjEsy$%s>_Zoc z2jC&N^BUYEOI*qeq{<7hc0nLUni+kwv){~q+U;;?A!Xlw?6U6|p`Q)-V+yyny`|C@ z6d;ZQd=GJa*0Hta%zBXkmt;n{0B$+|nDq)|bPj^hEGAjbQ<|`410%^6KxlJMkVit6 zr(~XU)$-AHt?2i-HW?~v%C~zesM$E^1X|Dy^uTa+4?je9?_|0Ama=S4lZ3pdJY(5e zd7!%gFOsX(XEMzejOSSjnItc1Mq^q!s&{%>KqSd{a<%&O#3yM=PAdWnXvkdh8t9@T zR1(Qj5bkns?2jkTXyo|M$msBB;*)9c?r(ME6aVRv-&SqC?#ilwT9-^FQ1@dH5od*D zc~%=th!Wzu2bTroAig4QHg8pq>O=25Pr*~-cr%_~sP%*we5mFCu_t)sML7qrl`t$? z>D{8L2lGbp@J3nQS3EuC;(-b)v@y~C!FvCAegDn+&g1o6=elp~SR2h87#n2j2H#O_ zCw8aqg<+gWVb~J1E44?w`69LXJk8@Wfnz>Yu=yL}wh9{!V|?A|;_h}};Tx`Bn%$u) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2d9fd3d748b372fd6f30ea47551c96d309d537ff GIT binary patch literal 1811 zcmdUv-*4Mg6vwX8Kc~8;}*1AE~;yV+^Q-CRJTf+l_X_g*~*AW#Zd5YZ6!2 zF5B`HoQk8>y8vOY3AaT_Ky|RUw|; zC?x4;6sw%zP z@}k|`q55Zb*WRW~zI49HOLo_3Q%`5qc3RzS<1TCYFzW7>-o!LVze{a@hcX=|>UNtl zUw3TZuE{U(|5mF)B;JAvF8~dRGCZfJ40CFCJh$7Ec^3DZLj;&gB;Y=Dhh zBf**kU_`9*f=)KXB-p_GSK3A{2{d@olBamAkS~atHpTf=QVN}HlcUV)I_x4x@3lPN za(5Yi0Rq`p*P&g%nvL>{3yVv2>-usd%5-*}7K@bI^@a7Ndc*p3`DQ(m5gN68de4tU z+;Wt0_B(qXvO!iJ?plvNFm~JyH9UXcxW(K%wCQ_>xqE+$QpfOE(`dSk8XTb2iECBc zyC13X;zOeBHCSp4x>p6$C#RzN?bhHXpJ5TJS%%eOcrGG&;by(D&cQMjqqtjE1cNo;leLVm|Q;_6CFy z(LdlB3&qJmoIIR}b`I zQ#r5p{3twfk`)u3PLnw@O^$@sYKGwr5oNhrJq{sDm-uJ!3*-)Dcw7FyL{o~@5r@1R91+~BYKcHj) zB{m5=15U5Qz%(>mK~w0J(Y%QUD<1PHa6Uu*=n%%4BRm&EsT4@17*c{~Ce&(yRyzhl z#mi4~m(TE2$0EtH9Jgs%ac!hMxE$N9uNbN+%30P{t4%E{k{I>e_SZCm2@I|oFFpcT z-1yH?#azw|OU86Gul#xLlaA}`x9K(Z4!p%n#@ht*XIc=1zfz(g4iq9N&q(QaW&Eeo z538Y436#pQG94EK#WtoD@?_2NM}P~Yy$FU6Gb@yi@H MX<=+2gW=r%1^~iPJpcdz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b2a89bd66be16b1e5db41c0abba452c9636162c6 GIT binary patch literal 666 zcmY*XO^Xvj5beo#CNrzTW)mYyiyjPUM$ttO5h1Lej6w^Gr)IXNvuQIkJ#=?ABq#rb zKf;?oCp38Slv|ek0jsmw1&gUSujbLstE$l z$t)8vgoOGtQm&gVB(lfR zL}omKxkX=;{LY!t5%>o~Nu(8vm{dHPU#*L>-MR+F!iHnFBf8&^a^2Sh34+=N1=X5zQyuk7woYjx8Kt}1b{2P%>VR$= hZ!?+9QvOc$pmhJ2z6G!;d!Bb$s(6)caNu3t^$#KqzaRhr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df04219db1082ceda070e8751b88ba165b7229f5 GIT binary patch literal 142 zcmey&%ge<81ZQ^&WP<3&AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl<*{fzwFRQ=+T zQvHCU{H)aEl4AYf?8>y%)D->VqGbKjO#S%y%)HE!_;|g7%3B;Zx%nxjIjMFA zAc`tWf28slr-9ufvF1`7wLBa9T0&^Y3$VWwCtaUtn~$_K_5xSiXnO&VRVzP4ElnLV z)yAA_b#Q=PLb+qYp?H^LFR*qf?ht!Wb+#3`F7VZ#;msX@t&7w^22n6S>z5u%BVQf`lGQ!)$7FpG|j`g7YvfB0M3;DvvE{NK!xTHr|LrT=D1TUUnh zXK+)hi_W=Z)lBC0s2+B!0T<}SCMyjdj1{+iY`6JrrEQm$mTR>a^;%7@Fezy>X`{`G ziI@0(M7%7X%MZgyFEB*I zFk#5S31)Jh6pKSNO=F8jiyv#mNoqW$;d5dqu`wvph^cL)o`IvN8g-fMXP=5*<4iHm zc$=#pI91g5^|$qRcY7;eQTcUaukogPjBX0Mx!2rxd(AKCwm@rpYy0{!nmJc)oG5dL z%G{B%&|5iGr%%-RLv{X0UF#^UMF-?pWLUnI20c)p62Rj0Ydmo WLJ;-G0h~GmvuEH=UsmANjQbm7Wz(Mk literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/renderer.cpython-313.pyc b/src/ui/__pycache__/renderer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e25e332c3d5ce414905f414f264c6185f686b34 GIT binary patch literal 2190 zcmd5-O-vhC5Poa#u8n`N`7sno!$Kh70CrnKC~2FfDx?ILlzMSmDF2E zo7vgJV9TX#jd z@;l(@OJYsN1QXwsvwZVJx}^(Zelv@)ykC8&3kD_yMU&Ji-PlyIAS-%Fl{RC(r*_C| zQHJuXiwnyOV#aMha8@@+TR;2)GJAk-96yJwAa$gkK2x9GAxMfaRb zV^eI>(=xVz$cmiE&$$G2g3a49x42312i9XRk$7@`QdwGl$Gwv|T zU9g`^`da~7Kn#e%8RujIT7RKeiV@rG=vTz;WjCAoOH*+#?R~K4@R@L|y zo^m9`i=$4o4!~!R@ig~5^705mek9oU9BdV#1;jCNe2%f}5RG1=A^oVS(=yIVW!0dr zwe=`NcyOoeTG7a@MqQN6Dym9(e7huRGWBHF(+0k6P#=I2Q3?{-jJhh}dAuQAFAJii z>B4eRD?V*+l4MoMh;m1auIxGuE1Hgpu_k5m>!er)LN;5}49XL%DmY8s@b*<^t`&t_ zSTb^$2(n~If^<_-RB27cF<-K*31d#$LKYjD+?bG6h;GEFS1o2FLn&$%w;%|#%}@$h zH>5&|GQRTHkk0;B)&JqoSGLQzzg>l#g9a{?Fp-Qh!RbudF2s+!?>A4ADQdFPJe}8f z1!GmeD9`{*BvntVO5XMkw7fr2G#Sj^TOiZ2LMU^r_B? zN?_EtLa?qwY@55};B>wuRl0=yiUeD(E4s3#sNfDR!J92Doi9{a;PsX5(Y60gU7(zy z7%HZI8S5FMlG?NaYau_yp*`xi<{^X8q_KbwQ?M0{Jxncpwb)y_#A;k@x zG4!}I*5f+K#LG&YIMY8?+N2>F27kIBDO$QoOkM{r`+K8*%OF%mzXdwYt`RFRT3vV& zXy01AzgSzX&p*stfpgV`2HSRb>CRH^O8tCw$zsQ!_=8)s_h%d4V8b7-b>Dl-7JZg~ zpwV}F`!lO=vJo6@gd)#^o&aB+KL{hP?e5h(S8EsQBh{-GJNBnP)bIwj{P+B|1#>{$ z^R70$?Z30$yNNrA8n->NU9tMlnSB#G(>um)!irxuWAFXYY4*KHP^ z!}X#1{7%0$a^5^M1GWpHMyRW{WQ7LJb^-LENF&m>9kU`)v-=$A6TOYzf$fj1-bu4( zYA1Ez=i1Ky#d$gYpKj!BKXI_hJ@3@7k%2E48iDXu;$C8#t9RG&&IjLV=IG3B@)7r_ zV7>XFIlKH*(i~j*CGhbxH|jZcz}wel-^4~W@iRMg2t`6so_f-0xtK|(smI7G8ho|M z88FZQ6gyT{V$H5;wqLWf-y`w{n8|ClkN53DQ7)_a5}5@R>W%bX_&abj4D-yzGVEah QF_Fh;@Q@v4hMODz1?n1D{{R30 literal 0 HcmV?d00001 diff --git a/src/ui/__pycache__/theme.cpython-313.pyc b/src/ui/__pycache__/theme.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e12d7fffce996d97ea3e3e97cab3983c94d17bc GIT binary patch literal 1732 zcmZ8g%Wm676dg*U9+cnD_?1j-CypFjqGJoGfuMluh^ULEEj%)cpaF;xH4+(1q%frH zDBWbKuBuIdF0$yhKhW=J(T%}X(f$BaxBK29DYeK*Iy!UiW9}Wyy_uX;5Ug+in6Z9N zBlM@(j8;A-H}8PFMFerAA|jE5E|DZj0m>u|mz!@?NI7j9I z7sw*u5?KbkMOFY;$r|7~*#Nvv?f_zP7jTnok!^C1+$THa0V$AO^01hzN?am)FA`+` zMdBz$imT{liYHs8f=a)Z5K_oRzsXnwpP%BI$vRBg|6nQSw$-%2!*JS8kK-@nW4Y}$5Z zLPpFPZP0p~L57}ZGTXC^DB0#H)4ofaY*V2IcU%|O9nZukpPz;Ywm9vYmO@n_8qk*L1M;|gkO^wF>=rhy zmZ=zy?Q!aQ*s(FiUFZVW+tj$gb$}LkdUm6z_=$S!b!JNqm!E02bde5}swzHyy6*4> z;~+dz#mAS!9Db_a?y)$qQt{;$XUq=wm5QqRDWgwETQDc#`oSnnV9a!A3!0^|0`_zt z+H$T!A&i%=Ov@AbE8+6XQ7OU^T&H63 zzlwwbyeDJBxG<5UT!xAS(KH~c`bk7I?dhSaief*iM#^Kgd>r~{+GF+bFpN4(TkX(u7A8weL^Z;ZZv{=vet-Pk z@v!!;{NwFk*VNzg2fw5aZorDZD5U(1t~VS**ZCa~{G{teDSkqH3j}^wsM#kSr_pP( zkNFO4MS1Q5V33p~>3t$6rGll+T;lgsc2EdpU_lzmQ-gkx5j>0J$$=H*1fN3k#Nc@_ zE%*$Q^Mli1R`9tuTG&~6q{QYq9kN693?O%G(@=X8$ literal 0 HcmV?d00001 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