commit
f46e53000d
@ -0,0 +1,20 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Config (Keep local secrets private)
|
||||||
|
config.yaml
|
||||||
|
asl3_wx_announce/config.yaml
|
||||||
|
|
||||||
|
# Audio Output
|
||||||
|
*.wav
|
||||||
|
/tmp/asl3_wx/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
# ASL3 Weather Announcer
|
||||||
|
|
||||||
|
**ASL3 Weather Announcer** is a flexible, multi-country weather alert and reporting system designed for AllStarLink 3 (Asterisk) nodes.
|
||||||
|
|
||||||
|
It provides **automated verbal announcements** for:
|
||||||
|
* **Active Weather Alerts**: Warnings, watches, and advisories as they are issued.
|
||||||
|
* **Daily Reports**: Detailed forecast, current conditions, sunrise/sunset, and moon phase.
|
||||||
|
* **Time Announcements**: Current local time at start of report.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* **Multi-Provider Support**:
|
||||||
|
* 🇺🇸 **USA**: Uses National Weather Service (NWS) API.
|
||||||
|
* 🇨🇦 **Canada**: Uses Environment Canada data.
|
||||||
|
* *Extensible*: Plugin architecture allows adding more countries easily.
|
||||||
|
* **Smart Location**:
|
||||||
|
* **GPS/GNSS**: Automatically detects location using `gpsd`.
|
||||||
|
* **Static**: Configurable fallback lat/lon.
|
||||||
|
* **Auto-Zone**: Automatically monitors the correct County, Forecast Zone, and Fire Weather Zone for your location.
|
||||||
|
* **Customizable**:
|
||||||
|
* **Extra Zones**: Manually monitor adjacent counties or specific stations (e.g., `VAC001` or `ON/s0000430`).
|
||||||
|
* **Audio**: Works with `pico2wave`, `flite`, or any CLI TTS engine. Plays to multiple ASL3 nodes.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
On your ASL3 server (Debian/Raspbian):
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3-pip libttspico-utils gpsd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Package
|
||||||
|
1. Clone this repository to your scripts directory (e.g., `/etc/asterisk/scripts/`).
|
||||||
|
2. Install python dependencies:
|
||||||
|
```bash
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy the example config:
|
||||||
|
```bash
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `config.yaml`:
|
||||||
|
```yaml
|
||||||
|
location:
|
||||||
|
type: auto # Use 'auto' for GPS, or 'static' for fixed lat/lon
|
||||||
|
# latitude: 45.123
|
||||||
|
# longitude: -75.123
|
||||||
|
|
||||||
|
voice:
|
||||||
|
tts_command: 'pico2wave -w {file} "{text}"'
|
||||||
|
|
||||||
|
audio:
|
||||||
|
nodes:
|
||||||
|
- "1966" # Your Private Node
|
||||||
|
- "92394" # Your Public Node
|
||||||
|
|
||||||
|
alerts:
|
||||||
|
min_severity: "Watch"
|
||||||
|
extra_zones: # Optional: Monitor extra areas
|
||||||
|
- "VAC001" # US County FIPS
|
||||||
|
- "ON/s0000430" # Canadian Station ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Test Full Report
|
||||||
|
Announce current conditions, forecast, and time immediately:
|
||||||
|
```bash
|
||||||
|
python3 -m asl3_wx_announce.main --config config.yaml --report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Alert Monitor
|
||||||
|
Run in the background to announce *new* alerts as they happen:
|
||||||
|
```bash
|
||||||
|
python3 -m asl3_wx_announce.main --config config.yaml --monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduled Hourly Reports
|
||||||
|
To announce the weather every hour, add to `crontab -u asterisk -e`:
|
||||||
|
```cron
|
||||||
|
0 * * * * /usr/bin/python3 -m asl3_wx_announce.main --config /path/to/config.yaml --report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
Pull requests are welcome! See `provider/` directory to add support for new countries.
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT License
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
class AudioHandler:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.config = config
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
# Default to echo if no TTS configured (just for safety)
|
||||||
|
self.tts_template = config.get('voice', {}).get('tts_command', 'echo "{text}" > {file}')
|
||||||
|
self.output_dir = "/tmp/asl3_wx"
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def generate_audio(self, text: str, filename: str = "announcement.wav") -> str:
|
||||||
|
filepath = os.path.join(self.output_dir, filename)
|
||||||
|
|
||||||
|
# Simple cleanup?
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
cmd = self.tts_template.format(file=filepath, text=text)
|
||||||
|
self.logger.info(f"Generating audio: {cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, shell=True, check=True)
|
||||||
|
return filepath
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
self.logger.error(f"TTS Failed: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def play_on_nodes(self, filepath: str, nodes: List[str]):
|
||||||
|
# Asterisk uses file path WITHOUT extension usually for play commands,
|
||||||
|
# but rpt localplay might require full path or specific format.
|
||||||
|
# Usually: rpt localplay <node> <path_no_ext>
|
||||||
|
path_no_ext = os.path.splitext(filepath)[0]
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
asterisk_cmd = f'asterisk -rx "rpt localplay {node} {path_no_ext}"'
|
||||||
|
self.logger.info(f"Playing on {node}: {asterisk_cmd}")
|
||||||
|
try:
|
||||||
|
subprocess.run(asterisk_cmd, shell=True, check=True)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Playback failed on {node}: {e}")
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
from typing import Tuple, Dict, Optional
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
|
||||||
|
class LocationService:
|
||||||
|
def __init__(self, config: Dict):
|
||||||
|
self.config = config
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_coordinates(self) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Returns (lat, lon).
|
||||||
|
Tries GPS first if configured, then falls back to static config.
|
||||||
|
"""
|
||||||
|
if self.config.get('location', {}).get('type') == 'auto':
|
||||||
|
try:
|
||||||
|
return self._read_gpsd()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"GPS read failed: {e}. Falling back to static.")
|
||||||
|
|
||||||
|
# Static fallback
|
||||||
|
lat = self.config.get('location', {}).get('latitude')
|
||||||
|
lon = self.config.get('location', {}).get('longitude')
|
||||||
|
if lat is None or lon is None:
|
||||||
|
raise ValueError("No valid location coordinates found in config or GPS.")
|
||||||
|
return float(lat), float(lon)
|
||||||
|
|
||||||
|
def _read_gpsd(self) -> Tuple[float, float]:
|
||||||
|
# Simple socket reader for GPSD standard port 2947
|
||||||
|
# We start the WATCH command and wait for a TPV report
|
||||||
|
try:
|
||||||
|
with socket.create_connection(('localhost', 2947), timeout=2) as sock:
|
||||||
|
sock.sendall(b'?WATCH={"enable":true,"json":true}\n')
|
||||||
|
fp = sock.makefile()
|
||||||
|
for line in fp:
|
||||||
|
data = json.loads(line)
|
||||||
|
if data.get('class') == 'TPV':
|
||||||
|
lat = data.get('lat')
|
||||||
|
lon = data.get('lon')
|
||||||
|
if lat and lon:
|
||||||
|
return lat, lon
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
raise RuntimeError("No TPV data received from GPSD")
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
import argparse
|
||||||
|
import yaml
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from .models import AlertSeverity
|
||||||
|
from .location import LocationService
|
||||||
|
from .provider.factory import get_provider_instance
|
||||||
|
from .provider.astro import AstroProvider
|
||||||
|
from .narrator import Narrator
|
||||||
|
from .audio import AudioHandler
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("asl3_wx")
|
||||||
|
|
||||||
|
def load_config(path):
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def do_full_report(config):
|
||||||
|
loc_svc = LocationService(config)
|
||||||
|
lat, lon = loc_svc.get_coordinates()
|
||||||
|
|
||||||
|
# Provider
|
||||||
|
prov_code = config.get('location', {}).get('provider')
|
||||||
|
provider = get_provider_instance(CountryCode=prov_code, Lat=lat, Lon=lon, Config=config)
|
||||||
|
|
||||||
|
# Fetch Data
|
||||||
|
loc_info = provider.get_location_info(lat, lon)
|
||||||
|
logger.info(f"Resolved Location: {loc_info.city}, {loc_info.region} ({loc_info.country_code})")
|
||||||
|
|
||||||
|
conditions = provider.get_conditions(lat, lon)
|
||||||
|
forecast = provider.get_forecast(lat, lon)
|
||||||
|
alerts = provider.get_alerts(lat, lon)
|
||||||
|
|
||||||
|
# Astro
|
||||||
|
astro = AstroProvider()
|
||||||
|
sun_info = astro.get_astro_info(loc_info)
|
||||||
|
|
||||||
|
# Narrate
|
||||||
|
narrator = Narrator()
|
||||||
|
text = narrator.build_full_report(loc_info, conditions, forecast, alerts, sun_info)
|
||||||
|
logger.info(f"Report Text: {text}")
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
handler = AudioHandler(config)
|
||||||
|
wav_file = handler.generate_audio(text, "report.wav")
|
||||||
|
|
||||||
|
# Play
|
||||||
|
nodes = config.get('audio', {}).get('nodes', [])
|
||||||
|
handler.play_on_nodes(wav_file, nodes)
|
||||||
|
|
||||||
|
def monitor_loop(config):
|
||||||
|
interval = config.get('alerts', {}).get('check_interval_minutes', 10) * 60
|
||||||
|
known_alerts = set()
|
||||||
|
|
||||||
|
loc_svc = LocationService(config)
|
||||||
|
lat, lon = loc_svc.get_coordinates()
|
||||||
|
prov_code = config.get('location', {}).get('provider')
|
||||||
|
provider = get_provider_instance(CountryCode=prov_code, Lat=lat, Lon=lon, Config=config)
|
||||||
|
narrator = Narrator()
|
||||||
|
handler = AudioHandler(config)
|
||||||
|
nodes = config.get('audio', {}).get('nodes', [])
|
||||||
|
|
||||||
|
logger.info("Starting Alert Monitor...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
alerts = provider.get_alerts(lat, lon)
|
||||||
|
current_ids = {a.id for a in alerts}
|
||||||
|
|
||||||
|
new_alerts = []
|
||||||
|
for a in alerts:
|
||||||
|
if a.id not in known_alerts:
|
||||||
|
new_alerts.append(a)
|
||||||
|
known_alerts.add(a.id)
|
||||||
|
|
||||||
|
# Announce new items
|
||||||
|
if new_alerts:
|
||||||
|
logger.info(f"New Alerts detected: {len(new_alerts)}")
|
||||||
|
text = narrator.announce_alerts(new_alerts)
|
||||||
|
wav = handler.generate_audio(text, "alert.wav")
|
||||||
|
handler.play_on_nodes(wav, nodes)
|
||||||
|
|
||||||
|
# Cleanup expired from known
|
||||||
|
known_alerts = known_alerts.intersection(current_ids)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Monitor error: {e}")
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="ASL3 Weather Announcer")
|
||||||
|
parser.add_argument("--config", default="config.yaml", help="Path to config file")
|
||||||
|
parser.add_argument("--report", action="store_true", help="Play full weather report now")
|
||||||
|
parser.add_argument("--monitor", action="store_true", help="Run in continuous monitor mode")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
config = load_config(args.config)
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
do_full_report(config)
|
||||||
|
elif args.monitor:
|
||||||
|
monitor_loop(config)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class AlertSeverity(str, Enum):
|
||||||
|
UNKNOWN = "Unknown"
|
||||||
|
ADVISORY = "Advisory"
|
||||||
|
WATCH = "Watch"
|
||||||
|
WARNING = "Warning"
|
||||||
|
CRITICAL = "Critical"
|
||||||
|
|
||||||
|
class WeatherAlert(BaseModel):
|
||||||
|
id: str
|
||||||
|
severity: AlertSeverity
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
instruction: Optional[str] = None
|
||||||
|
area_description: str
|
||||||
|
effective: datetime
|
||||||
|
expires: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
now = datetime.now(self.expires.tzinfo)
|
||||||
|
return self.effective <= now < self.expires
|
||||||
|
|
||||||
|
class WeatherForecast(BaseModel):
|
||||||
|
period_name: str # e.g., "Today", "Tonight", "Monday"
|
||||||
|
high_temp: Optional[float] # Celsius
|
||||||
|
low_temp: Optional[float] # Celsius
|
||||||
|
summary: str
|
||||||
|
precip_probability: Optional[int]
|
||||||
|
|
||||||
|
class CurrentConditions(BaseModel):
|
||||||
|
temperature: float # Celsius
|
||||||
|
humidity: Optional[int]
|
||||||
|
wind_speed: Optional[float] # km/h
|
||||||
|
wind_direction: Optional[str]
|
||||||
|
description: str
|
||||||
|
|
||||||
|
class LocationInfo(BaseModel):
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
city: str
|
||||||
|
region: str # State/Province
|
||||||
|
country_code: str
|
||||||
|
timezone: str
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
|
||||||
|
|
||||||
|
class Narrator:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str:
|
||||||
|
wind = ""
|
||||||
|
if current.wind_speed and current.wind_speed > 5:
|
||||||
|
wind = f", with winds from the {current.wind_direction} at {int(current.wind_speed)} kilometers per hour"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Current conditions for {loc.city}, {loc.region}. "
|
||||||
|
f"The temperature is {int(current.temperature)} degrees celsius. "
|
||||||
|
f"Conditions are {current.description}{wind}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def announce_forecast(self, forecasts: List[WeatherForecast]) -> str:
|
||||||
|
if not forecasts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = "Here is the forecast. "
|
||||||
|
for f in forecasts[:3]: # Read first 3 periods
|
||||||
|
temp = ""
|
||||||
|
if f.high_temp is not None:
|
||||||
|
temp = f" with a high of {int(f.high_temp)}"
|
||||||
|
elif f.low_temp is not None:
|
||||||
|
temp = f" with a low of {int(f.low_temp)}"
|
||||||
|
|
||||||
|
text += f"{f.period_name}: {f.summary}{temp}. "
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def announce_alerts(self, alerts: List[WeatherAlert]) -> str:
|
||||||
|
if not alerts:
|
||||||
|
return "There are no active weather alerts."
|
||||||
|
|
||||||
|
text = f"There are {len(alerts)} active weather alerts. "
|
||||||
|
for a in alerts:
|
||||||
|
# "A Severe Thunderstorm Warning is in effect until 5 PM."
|
||||||
|
expires_str = a.expires.strftime("%I %M %p")
|
||||||
|
text += f"A {a.title} is in effect until {expires_str}. "
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def build_full_report(self, loc: LocationInfo, current: CurrentConditions, forecast: List[WeatherForecast], alerts: List[WeatherAlert], sun_info: str = "") -> str:
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Time string: "10 30 PM"
|
||||||
|
now_str = datetime.now().strftime("%I %M %p")
|
||||||
|
# Remove leading zero from hour if desired, but TTS usually handles "09" fine.
|
||||||
|
# For cleaner TTS:
|
||||||
|
if now_str.startswith("0"):
|
||||||
|
now_str = now_str[1:]
|
||||||
|
|
||||||
|
parts.append(f"Good day. The time is {now_str}.")
|
||||||
|
parts.append(f"This is the automated weather report for {loc.city}.")
|
||||||
|
|
||||||
|
if alerts:
|
||||||
|
parts.append("Please be advised: " + self.announce_alerts(alerts))
|
||||||
|
|
||||||
|
parts.append(self.announce_conditions(loc, current))
|
||||||
|
parts.append(self.announce_forecast(forecast))
|
||||||
|
|
||||||
|
if sun_info:
|
||||||
|
parts.append(sun_info)
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from astral import LocationInfo as AstralLoc
|
||||||
|
from astral.sun import sun
|
||||||
|
from astral.moon import phase
|
||||||
|
from ..models import LocationInfo
|
||||||
|
|
||||||
|
class AstroProvider:
|
||||||
|
def get_astro_info(self, loc: LocationInfo) -> str:
|
||||||
|
try:
|
||||||
|
# Astral uses its own LocationInfo
|
||||||
|
City = AstralLoc(loc.city, loc.region, loc.timezone, loc.latitude, loc.longitude)
|
||||||
|
s = sun(City.observer, date=datetime.now(), tzinfo=City.timezone)
|
||||||
|
|
||||||
|
sunrise = s['sunrise'].strftime("%I %M %p")
|
||||||
|
sunset = s['sunset'].strftime("%I %M %p")
|
||||||
|
|
||||||
|
# Moon phase: 0..27
|
||||||
|
ph = phase(datetime.now())
|
||||||
|
moon_desc = self._describe_phase(ph)
|
||||||
|
|
||||||
|
return f"Sunrise is at {sunrise}. Sunset is at {sunset}. The moon is {moon_desc}."
|
||||||
|
except Exception as e:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _describe_phase(self, day: float) -> str:
|
||||||
|
if day < 1: return "New"
|
||||||
|
if day < 7: return "Waxing Crescent"
|
||||||
|
if day < 8: return "First Quarter"
|
||||||
|
if day < 14: return "Waxing Gibbous"
|
||||||
|
if day < 15: return "Full"
|
||||||
|
if day < 21: return "Waning Gibbous"
|
||||||
|
if day < 22: return "Last Quarter"
|
||||||
|
return "Waning Crescent"
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Tuple
|
||||||
|
from ..models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
|
||||||
|
|
||||||
|
class WeatherProvider(ABC):
|
||||||
|
"""
|
||||||
|
Abstract Base Class for Country-Specific Weather Providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize provider with arbitrary config.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_location_info(self, lat: float, lon: float) -> LocationInfo:
|
||||||
|
"""
|
||||||
|
Resolve lat/lon to standard location info (City, Region, etc.).
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_conditions(self, lat: float, lon: float) -> CurrentConditions:
|
||||||
|
"""
|
||||||
|
Get current weather observations.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_forecast(self, lat: float, lon: float) -> List[WeatherForecast]:
|
||||||
|
"""
|
||||||
|
Get daily forecast periods.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_alerts(self, lat: float, lon: float) -> List[WeatherAlert]:
|
||||||
|
"""
|
||||||
|
Get active weather alerts.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from env_canada import ECData
|
||||||
|
from ..models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert, AlertSeverity
|
||||||
|
from .base import WeatherProvider
|
||||||
|
|
||||||
|
class ECProvider(WeatherProvider):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.points_cache = {}
|
||||||
|
self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', [])
|
||||||
|
|
||||||
|
def _get_ec_data(self, lat, lon):
|
||||||
|
# ECData auto-selects station based on lat/lon
|
||||||
|
ec = ECData(coordinates=(lat, lon))
|
||||||
|
ec.update()
|
||||||
|
return ec
|
||||||
|
|
||||||
|
def get_location_info(self, lat: float, lon: float) -> LocationInfo:
|
||||||
|
ec = self._get_ec_data(lat, lon)
|
||||||
|
# ECData metadata is nested
|
||||||
|
meta = ec.metadata
|
||||||
|
return LocationInfo(
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
city=meta.get('location', 'Unknown'),
|
||||||
|
region=meta.get('province', 'Canada'),
|
||||||
|
country_code="CA",
|
||||||
|
timezone="Unknown" # EC lib doesn't trivialy expose TZ
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_conditions(self, lat: float, lon: float) -> CurrentConditions:
|
||||||
|
ec = self._get_ec_data(lat, lon)
|
||||||
|
cond = ec.conditions
|
||||||
|
|
||||||
|
return CurrentConditions(
|
||||||
|
temperature=float(cond.get('temperature', {}).get('value', 0)),
|
||||||
|
humidity=int(cond.get('humidity', {}).get('value') or 0),
|
||||||
|
wind_speed=float(cond.get('wind_speed', {}).get('value') or 0),
|
||||||
|
wind_direction=cond.get('wind_direction', {}).get('value'),
|
||||||
|
description=cond.get('condition', 'Unknown')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_forecast(self, lat: float, lon: float) -> List[WeatherForecast]:
|
||||||
|
ec = self._get_ec_data(lat, lon)
|
||||||
|
daily = ec.daily_forecasts
|
||||||
|
|
||||||
|
forecasts = []
|
||||||
|
for d in daily:
|
||||||
|
forecasts.append(WeatherForecast(
|
||||||
|
period_name=d.get('period'),
|
||||||
|
high_temp=float(d.get('temperature')) if d.get('temperature') else None,
|
||||||
|
low_temp=None, # EC daily structure is period-based (e.g. "Monday", "Monday Night")
|
||||||
|
summary=d.get('text_summary', ''),
|
||||||
|
precip_probability=int(d.get('precip_probability') or 0)
|
||||||
|
))
|
||||||
|
return forecasts
|
||||||
|
|
||||||
|
def get_alerts(self, lat: float, lon: float) -> List[WeatherAlert]:
|
||||||
|
ec_objects = [self._get_ec_data(lat, lon)]
|
||||||
|
|
||||||
|
# Add extra zones (Station IDs e.g., 'ON/s0000430')
|
||||||
|
for zone_id in self.extra_zones:
|
||||||
|
if "/" in zone_id: # Basic check if it looks like EC station ID
|
||||||
|
try:
|
||||||
|
ec_objects.append(ECData(station_id=zone_id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
results = []
|
||||||
|
now = datetime.now()
|
||||||
|
seen_titles = set()
|
||||||
|
|
||||||
|
for ec in ec_objects:
|
||||||
|
try:
|
||||||
|
if not getattr(ec, 'conditions', None): # Ensure updated
|
||||||
|
ec.update()
|
||||||
|
|
||||||
|
for a in ec.alerts:
|
||||||
|
title = a.get('title', '')
|
||||||
|
if title in seen_titles: continue
|
||||||
|
seen_titles.add(title)
|
||||||
|
|
||||||
|
# Mapping severity roughly
|
||||||
|
severity = AlertSeverity.UNKNOWN
|
||||||
|
if "warning" in title.lower(): severity = AlertSeverity.WARNING
|
||||||
|
elif "watch" in title.lower(): severity = AlertSeverity.WATCH
|
||||||
|
elif "advisory" in title.lower(): severity = AlertSeverity.ADVISORY
|
||||||
|
elif "statement" in title.lower(): severity = AlertSeverity.ADVISORY
|
||||||
|
|
||||||
|
# Using current time as dummy effective/expires if missing
|
||||||
|
eff = a.get('date')
|
||||||
|
|
||||||
|
results.append(WeatherAlert(
|
||||||
|
id=title,
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
description=a.get('detail', ''),
|
||||||
|
area_description=ec.metadata.get('location', 'Local Area'),
|
||||||
|
effective=now,
|
||||||
|
expires=now
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import reverse_geocoder as rg
|
||||||
|
from typing import Type
|
||||||
|
from .base import WeatherProvider
|
||||||
|
from .nws import NWSProvider
|
||||||
|
from .ec import ECProvider
|
||||||
|
|
||||||
|
# Registry to hold provider classes
|
||||||
|
_PROVIDERS = {
|
||||||
|
"US": NWSProvider,
|
||||||
|
"CA": ECProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
def register_provider(country_code: str, provider_cls: Type[WeatherProvider]):
|
||||||
|
_PROVIDERS[country_code.upper()] = provider_cls
|
||||||
|
|
||||||
|
def get_provider_class(lat: float, lon: float) -> Type[WeatherProvider]:
|
||||||
|
"""
|
||||||
|
Determines the appropriate provider class based on location.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# result is a list of dicts: [{'lat': '...', 'lon': '...', 'name': 'City', 'admin1': 'Region', 'cc': 'US'}]
|
||||||
|
results = rg.search((lat, lon))
|
||||||
|
cc = results[0]['cc'].upper()
|
||||||
|
|
||||||
|
if cc in _PROVIDERS:
|
||||||
|
return _PROVIDERS[cc]
|
||||||
|
|
||||||
|
print(f"Warning: No explicit provider for {cc}, defaulting to generic if possible or erroring.")
|
||||||
|
raise ValueError(f"No weather provider found for country code: {cc}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def get_provider_instance(CountryCode: str = None, Lat: float = None, Lon: float = None, Config: dict = None) -> WeatherProvider:
|
||||||
|
if CountryCode:
|
||||||
|
cls = _PROVIDERS.get(CountryCode.upper())
|
||||||
|
if not cls:
|
||||||
|
raise ValueError(f"Unknown country code: {CountryCode}")
|
||||||
|
return cls(**(Config or {}))
|
||||||
|
|
||||||
|
if Lat is not None and Lon is not None:
|
||||||
|
cls = get_provider_class(Lat, Lon)
|
||||||
|
return cls(**(Config or {}))
|
||||||
|
|
||||||
|
raise ValueError("Must provide either CountryCode or Lat/Lon to select provider.")
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from dateutil.parser import parse as parse_date
|
||||||
|
from ..models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert, AlertSeverity
|
||||||
|
from .base import WeatherProvider
|
||||||
|
|
||||||
|
class NWSProvider(WeatherProvider):
|
||||||
|
USER_AGENT = "(asl3-wx-announce, contact@example.com)"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.points_cache = {}
|
||||||
|
self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', [])
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {"User-Agent": self.USER_AGENT, "Accept": "application/geo+json"}
|
||||||
|
|
||||||
|
def _get_point_metadata(self, lat, lon):
|
||||||
|
key = f"{lat},{lon}"
|
||||||
|
if key in self.points_cache:
|
||||||
|
return self.points_cache[key]
|
||||||
|
|
||||||
|
url = f"https://api.weather.gov/points/{lat},{lon}"
|
||||||
|
resp = requests.get(url, headers=self._headers())
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self.points_cache[key] = data['properties']
|
||||||
|
return data['properties']
|
||||||
|
|
||||||
|
def get_location_info(self, lat: float, lon: float) -> LocationInfo:
|
||||||
|
meta = self._get_point_metadata(lat, lon)
|
||||||
|
props = meta.get('relativeLocation', {}).get('properties', {})
|
||||||
|
return LocationInfo(
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
city=props.get('city', 'Unknown'),
|
||||||
|
region=props.get('state', 'US'),
|
||||||
|
country_code="US",
|
||||||
|
timezone=meta.get('timeZone', 'UTC')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_conditions(self, lat: float, lon: float) -> CurrentConditions:
|
||||||
|
# NWS "current conditions" often requires finding a nearby station first
|
||||||
|
# For simplicity, we can sometimes pull from the gridpoint "now", but standard practice
|
||||||
|
# is to hit the stations endpoint.
|
||||||
|
meta = self._get_point_metadata(lat, lon)
|
||||||
|
stations_url = meta['observationStations']
|
||||||
|
|
||||||
|
# Get first station
|
||||||
|
s_resp = requests.get(stations_url, headers=self._headers())
|
||||||
|
s_data = s_resp.json()
|
||||||
|
station_id = s_data['features'][0]['properties']['stationIdentifier']
|
||||||
|
|
||||||
|
# Get obs
|
||||||
|
obs_url = f"https://api.weather.gov/stations/{station_id}/observations/latest"
|
||||||
|
o_resp = requests.get(obs_url, headers=self._headers())
|
||||||
|
props = o_resp.json()['properties']
|
||||||
|
|
||||||
|
temp_c = props.get('temperature', {}).get('value')
|
||||||
|
return CurrentConditions(
|
||||||
|
temperature=temp_c if temp_c is not None else 0.0,
|
||||||
|
humidity=props.get('relativeHumidity', {}).get('value'),
|
||||||
|
wind_speed=props.get('windSpeed', {}).get('value'),
|
||||||
|
wind_direction=str(props.get('windDirection', {}).get('value')),
|
||||||
|
description=props.get('textDescription', 'Unknown')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_forecast(self, lat: float, lon: float) -> List[WeatherForecast]:
|
||||||
|
meta = self._get_point_metadata(lat, lon)
|
||||||
|
forecast_url = meta['forecast']
|
||||||
|
|
||||||
|
resp = requests.get(forecast_url, headers=self._headers())
|
||||||
|
periods = resp.json()['properties']['periods']
|
||||||
|
|
||||||
|
forecasts = []
|
||||||
|
for p in periods[:4]: # Just next few periods
|
||||||
|
# NWS gives temp in F sometimes, but API usually defaults to F.
|
||||||
|
# We strictly want models in C, so check unit.
|
||||||
|
temp = p.get('temperature')
|
||||||
|
unit = p.get('temperatureUnit')
|
||||||
|
if unit == 'F':
|
||||||
|
temp = (temp - 32) * 5.0/9.0
|
||||||
|
|
||||||
|
summary = p['detailedForecast']
|
||||||
|
forecasts.append(WeatherForecast(
|
||||||
|
period_name=p['name'],
|
||||||
|
high_temp=temp if p['isDaytime'] else None,
|
||||||
|
low_temp=temp if not p['isDaytime'] else None,
|
||||||
|
summary=summary,
|
||||||
|
precip_probability=p.get('probabilityOfPrecipitation', {}).get('value')
|
||||||
|
))
|
||||||
|
return forecasts
|
||||||
|
|
||||||
|
def get_alerts(self, lat: float, lon: float) -> List[WeatherAlert]:
|
||||||
|
meta = self._get_point_metadata(lat, lon)
|
||||||
|
|
||||||
|
# Extract zone IDs (e.g., https://api.weather.gov/zones/forecast/MDZ013)
|
||||||
|
# We want the basename
|
||||||
|
def get_id(url):
|
||||||
|
return url.split('/')[-1] if url else None
|
||||||
|
|
||||||
|
zones = set(self.extra_zones)
|
||||||
|
zones.add(get_id(meta.get('county')))
|
||||||
|
zones.add(get_id(meta.get('fireWeatherZone')))
|
||||||
|
zones.add(get_id(meta.get('forecastZone')))
|
||||||
|
zones.discard(None) # Remove None if any failed
|
||||||
|
|
||||||
|
if not zones:
|
||||||
|
# Fallback to point if no zones found (unlikely)
|
||||||
|
url = f"https://api.weather.gov/alerts/active?point={lat},{lon}"
|
||||||
|
else:
|
||||||
|
# Join zones: active?zone=MDZ013,MDC031
|
||||||
|
zone_str = ",".join(zones)
|
||||||
|
url = f"https://api.weather.gov/alerts/active?zone={zone_str}"
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=self._headers())
|
||||||
|
features = resp.json().get('features', [])
|
||||||
|
|
||||||
|
alerts = []
|
||||||
|
for f in features:
|
||||||
|
props = f['properties']
|
||||||
|
|
||||||
|
# Map severity
|
||||||
|
sev_str = props.get('severity', 'Unknown')
|
||||||
|
severity = AlertSeverity.UNKNOWN
|
||||||
|
if sev_str == 'Severe': severity = AlertSeverity.WARNING
|
||||||
|
|
||||||
|
event_type = props.get('event', '').upper()
|
||||||
|
if "WARNING" in event_type: severity = AlertSeverity.WARNING
|
||||||
|
elif "WATCH" in event_type: severity = AlertSeverity.WATCH
|
||||||
|
elif "ADVISORY" in event_type: severity = AlertSeverity.ADVISORY
|
||||||
|
elif "STATEMENT" in event_type: severity = AlertSeverity.ADVISORY
|
||||||
|
|
||||||
|
alerts.append(WeatherAlert(
|
||||||
|
id=props['id'],
|
||||||
|
severity=severity,
|
||||||
|
title=props['event'],
|
||||||
|
description=props['description'] or "",
|
||||||
|
instruction=props.get('instruction'),
|
||||||
|
area_description=props.get('areaDesc', ''),
|
||||||
|
effective=parse_date(props['effective']),
|
||||||
|
expires=parse_date(props['expires'])
|
||||||
|
))
|
||||||
|
|
||||||
|
return alerts
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
location:
|
||||||
|
type: auto # or static
|
||||||
|
# latitude: 45.4215
|
||||||
|
# longitude: -75.6972
|
||||||
|
# provider: CA # Optional: force US or CA
|
||||||
|
|
||||||
|
voice:
|
||||||
|
# Example utilizing pico2wave (sudo apt install libttspico-utils)
|
||||||
|
tts_command: 'pico2wave -w {file} "{text}"'
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- "YOUR_NODE_NUMBER_HERE"
|
||||||
|
|
||||||
|
alerts:
|
||||||
|
check_interval_minutes: 10
|
||||||
|
min_severity: "Watch"
|
||||||
|
# Optional: List of NWS Zones/Counties to monitor in addition to current location
|
||||||
|
# extra_zones:
|
||||||
|
# - "VAC001"
|
||||||
|
# - "MDZ013"
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
requests
|
||||||
|
astral
|
||||||
|
pydantic
|
||||||
|
pyyaml
|
||||||
|
env_canada
|
||||||
|
reverse_geocoder
|
||||||
|
numpy
|
||||||
Loading…
Reference in new issue