Skyfeed v0.1.0.0 - initial scaffolding, docs, and architecture
This commit is contained in:
commit
a23ed98e80
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
BIN
assets/fonts/Modeseven.ttf
Executable file
BIN
assets/fonts/Modeseven.ttf
Executable file
Binary file not shown.
9
cache/alert.json
vendored
Normal file
9
cache/alert.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
0
cache/meta.json
vendored
Normal file
0
cache/meta.json
vendored
Normal file
13
cache/weather.json
vendored
Normal file
13
cache/weather.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
35
config/config.json
Normal file
35
config/config.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
docs/API/cli.md
Normal file
0
docs/API/cli.md
Normal file
0
docs/API/core.md
Normal file
0
docs/API/core.md
Normal file
0
docs/API/localization.md
Normal file
0
docs/API/localization.md
Normal file
119
docs/API/ui.md
Normal file
119
docs/API/ui.md
Normal file
|
|
@ -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)
|
||||||
0
docs/API/utils.md
Normal file
0
docs/API/utils.md
Normal file
0
docs/ARCHITECTURE.md
Normal file
0
docs/ARCHITECTURE.md
Normal file
7
docs/CHANGELOG.md
Normal file
7
docs/CHANGELOG.md
Normal file
|
|
@ -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
|
||||||
0
docs/CONTRIBUTING.md
Normal file
0
docs/CONTRIBUTING.md
Normal file
0
docs/DESIGN/layout_spec.md
Normal file
0
docs/DESIGN/layout_spec.md
Normal file
0
docs/DESIGN/theme_reference.md
Normal file
0
docs/DESIGN/theme_reference.md
Normal file
186
docs/DESIGN/visual_style.md
Normal file
186
docs/DESIGN/visual_style.md
Normal file
|
|
@ -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)
|
||||||
0
docs/LOCALIZATION.md
Normal file
0
docs/LOCALIZATION.md
Normal file
0
docs/README.md
Normal file
0
docs/README.md
Normal file
0
docs/ROADMAP.md
Normal file
0
docs/ROADMAP.md
Normal file
1
pyproject.toml
Normal file
1
pyproject.toml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
version = "0.1.0"
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
BIN
src/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
src/cli/__init__.py
Normal file
0
src/cli/__init__.py
Normal file
BIN
src/cli/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/cli/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/cli/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/cli/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
55
src/cli/main.py
Normal file
55
src/cli/main.py
Normal file
|
|
@ -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()
|
||||||
141
src/config/config_manager.py
Normal file
141
src/config/config_manager.py
Normal file
|
|
@ -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()}")
|
||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/alert_parser.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/alert_parser.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/cache_manager.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/cache_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/weather_client.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/weather_client.cpython-313.pyc
Normal file
Binary file not shown.
17
src/core/alert_parser.py
Normal file
17
src/core/alert_parser.py
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
30
src/core/cache_manager.py
Normal file
30
src/core/cache_manager.py
Normal file
|
|
@ -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 []
|
||||||
122
src/core/location_manager.py
Normal file
122
src/core/location_manager.py
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
375
src/core/weather_client.py
Normal file
375
src/core/weather_client.py
Normal file
|
|
@ -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.
|
||||||
|
<dateTime name="observation">
|
||||||
|
<timeStamp>2025-11-09T19:05:00Z</timeStamp>
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
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 []
|
||||||
0
src/localization/__init__.py
Normal file
0
src/localization/__init__.py
Normal file
62
src/localization/en_CA.json
Normal file
62
src/localization/en_CA.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/localization/fr_CA.json
Normal file
62
src/localization/fr_CA.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/localization/iu_CA.json
Normal file
62
src/localization/iu_CA.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/localization/localization_manager.py
Normal file
103
src/localization/localization_manager.py
Normal file
|
|
@ -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)")
|
||||||
0
src/ui/__init__.py
Normal file
0
src/ui/__init__.py
Normal file
BIN
src/ui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/ui/__pycache__/app.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/ui/__pycache__/renderer.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/renderer.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/ui/__pycache__/theme.cpython-313.pyc
Normal file
BIN
src/ui/__pycache__/theme.cpython-313.pyc
Normal file
Binary file not shown.
23
src/ui/app.py
Normal file
23
src/ui/app.py
Normal file
|
|
@ -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()
|
||||||
0
src/ui/fullscreen_view.py
Normal file
0
src/ui/fullscreen_view.py
Normal file
0
src/ui/layout_manager.py
Normal file
0
src/ui/layout_manager.py
Normal file
173
src/ui/minimal_view.py
Normal file
173
src/ui/minimal_view.py
Normal file
|
|
@ -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()
|
||||||
47
src/ui/renderer.py
Normal file
47
src/ui/renderer.py
Normal file
|
|
@ -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")
|
||||||
54
src/ui/theme.py
Normal file
54
src/ui/theme.py
Normal file
|
|
@ -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"])
|
||||||
0
src/utils/__init__.py
Normal file
0
src/utils/__init__.py
Normal file
0
src/utils/formatter.py
Normal file
0
src/utils/formatter.py
Normal file
0
src/utils/logger.py
Normal file
0
src/utils/logger.py
Normal file
0
src/utils/time_utils.py
Normal file
0
src/utils/time_utils.py
Normal file
178
src/utils/weather_symbols.py
Normal file
178
src/utils/weather_symbols.py
Normal file
|
|
@ -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"]
|
||||||
0
tests/test_alerts.py
Normal file
0
tests/test_alerts.py
Normal file
0
tests/test_location.py
Normal file
0
tests/test_location.py
Normal file
0
tests/test_ui_fullscreen.py
Normal file
0
tests/test_ui_fullscreen.py
Normal file
0
tests/test_ui_minimal.py
Normal file
0
tests/test_ui_minimal.py
Normal file
0
tests/test_weather.py
Normal file
0
tests/test_weather.py
Normal file
Loading…
Reference in New Issue
Block a user