Skyfeed v0.1.0.0 - initial scaffolding, docs, and architecture

This commit is contained in:
Stu Leak 2025-11-09 14:30:21 -05:00
commit e982d4d6aa
64 changed files with 1874 additions and 0 deletions

0
.gitignore vendored Normal file
View File

0
LICENSE Normal file
View File

BIN
assets/fonts/Modeseven.ttf Executable file

Binary file not shown.

9
cache/alert.json vendored Normal file
View 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
View File

13
cache/weather.json vendored Normal file
View 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
View 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
View File

0
docs/API/core.md Normal file
View File

0
docs/API/localization.md Normal file
View File

119
docs/API/ui.md Normal file
View 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
View File

0
docs/ARCHITECTURE.md Normal file
View File

7
docs/CHANGELOG.md Normal file
View 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
View File

View File

View File

186
docs/DESIGN/visual_style.md Normal file
View 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 Canadas weather, hazard, and advisory conditions.
All symbols are **terminal-safe**, **monospace-friendly**, and intentionally minimalist —
reflecting Skyfeeds 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.**
Skyfeeds 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
View File

0
docs/README.md Normal file
View File

0
docs/ROADMAP.md Normal file
View File

1
pyproject.toml Normal file
View File

@ -0,0 +1 @@
version = "0.1.0"

0
src/__init__.py Normal file
View File

Binary file not shown.

0
src/cli/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

55
src/cli/main.py Normal file
View 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()

View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
src/core/alert_parser.py Normal file
View 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 2540 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
View 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 []

View 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 MacdonaldCartier Int'l",
"distance_km": 0.0
}

375
src/core/weather_client.py Normal file
View 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 []

View File

View 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"
}
}
}

View 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"
}
}
}

View 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"
}
}
}

View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

23
src/ui/app.py Normal file
View 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()

View File

0
src/ui/layout_manager.py Normal file
View File

173
src/ui/minimal_view.py Normal file
View 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
View File

@ -0,0 +1,47 @@
"""
Renderer
--------
Draws Skyfeeds 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
View 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
View File

0
src/utils/formatter.py Normal file
View File

0
src/utils/logger.py Normal file
View File

0
src/utils/time_utils.py Normal file
View File

View 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
View File

0
tests/test_location.py Normal file
View File

View File

0
tests/test_ui_minimal.py Normal file
View File

0
tests/test_weather.py Normal file
View File