Initial commit of ASL3 Weather Announcer

main
swanie98635 2 months ago
commit d6ba3e62b8

20
.gitignore vendored

@ -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 sox
```
### 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,14 @@
[Unit]
Description=ASL3 Weather Announcer Monitor
After=network.target sound.target asterisk.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/asl3_wx_announce
ExecStart=/usr/bin/python3 -m asl3_wx_announce.main --monitor
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target

@ -0,0 +1,179 @@
import os
import subprocess
import logging
import time
import shutil
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.tmp_dir = "/tmp/asl3_wx"
os.makedirs(self.tmp_dir, exist_ok=True)
# Asterisk sounds directory
self.asterisk_sounds_dir = "/usr/share/asterisk/sounds/en"
def generate_audio(self, text: str, filename: str = "announcement.gsm") -> List[tuple[str, float]]:
# Sanitize text to prevent shell injection/breaking
text = text.replace('"', "'").replace('`', '').replace('(', '').replace(')', '')
# Check for pause delimiter
segments = text.split('[PAUSE]')
# Clean up empty segments
segments = [s.strip() for s in segments if s.strip()]
if not segments:
raise Exception("No text to generate")
final_files = []
try:
# Generate separate files for each segment
for i, segment in enumerate(segments):
# 1. Generate Raw WAV
raw_filename = f"raw_{os.path.splitext(filename)[0]}_{i}.wav"
raw_path = os.path.join(self.tmp_dir, raw_filename)
# Cleanup
if os.path.exists(raw_path):
os.remove(raw_path)
cmd = self.tts_template.format(file=raw_path, text=segment)
self.logger.info(f"Generating segment {i}: {cmd}")
subprocess.run(cmd, shell=True, check=True)
if not os.path.exists(raw_path) or os.path.getsize(raw_path) == 0:
raise Exception(f"TTS failed for segment {i}")
# Get Duration from WAV (Reliable)
duration = self.get_audio_duration(raw_path)
# 2. Convert to ASL3 format (GSM)
gsm_filename = f"asl3_wx_{os.path.splitext(filename)[0]}_{i}.gsm"
gsm_tmp_path = os.path.join(self.tmp_dir, gsm_filename)
self.convert_audio(raw_path, gsm_tmp_path)
# 3. Move to Asterisk Sounds Directory
dest_path = os.path.join(self.asterisk_sounds_dir, gsm_filename)
move_cmd = f"sudo mv {gsm_tmp_path} {dest_path}"
self.logger.info(f"Moving to sounds dir: {move_cmd}")
subprocess.run(move_cmd, shell=True, check=True)
# 4. Fix permissions
chmod_cmd = f"sudo chmod 644 {dest_path}"
subprocess.run(chmod_cmd, shell=True, check=True)
final_files.append((dest_path, duration))
return final_files
except subprocess.CalledProcessError as e:
self.logger.error(f"Audio Generation Failed: {e}")
raise e
except Exception as e:
self.logger.error(f"Error: {e}")
raise e
def convert_audio(self, input_path: str, output_path: str):
"""
Convert audio to 8000Hz, 1 channel, 16-bit signed integer PCM wav.
"""
# cleanup prior if exists
try:
if os.path.exists(output_path):
os.remove(output_path)
except OSError:
pass
cmd = f"sox {input_path} -r 8000 -c 1 -t gsm {output_path}"
self.logger.info(f"Converting audio: {cmd}")
subprocess.run(cmd, shell=True, check=True)
def get_audio_duration(self, filepath: str) -> float:
try:
# use sox to get duration
cmd = f"sox --i -D {filepath}"
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
return float(result.stdout.strip())
except Exception as e:
self.logger.error(f"Failed to get duration for {filepath}: {e}")
return 0.0
def play_on_nodes(self, audio_segments: List[tuple[str, float]], nodes: List[str]):
# Iterate through segments
for i, (filepath, duration) in enumerate(audio_segments):
filename = os.path.basename(filepath)
name_no_ext = os.path.splitext(filename)[0]
self.logger.info(f"Segment {i} duration: {duration}s")
# Play on all nodes (simultaneously-ish)
for node in nodes:
asterisk_cmd = f'sudo /usr/sbin/asterisk -rx "rpt playback {node} {name_no_ext}"'
self.logger.info(f"Playing segment {i} on {node}: {asterisk_cmd}")
try:
subprocess.run(asterisk_cmd, shell=True, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
self.logger.error(f"Playback failed on {node}. Return code: {e.returncode}")
self.logger.error(f"Stdout: {e.stdout}")
self.logger.error(f"Stderr: {e.stderr}")
# Wait for playback to finish + buffer
# Safe buffer: 1.5s
time.sleep(duration + 1.5)
# Wait for 5 seconds between segments (but not after the last one)
if i < len(audio_segments) - 1:
self.logger.info("Pausing 5s for unkey...")
time.sleep(5)
def ensure_alert_tone(self) -> tuple[str, float]:
"""
Generates the 2-second alternating alert tone if it doesn't exist.
Returns (filepath, duration)
"""
filename = "alert_tone.gsm"
dest_path = os.path.join(self.asterisk_sounds_dir, filename)
# We assume 2.0s duration for the generated tone
duration = 2.0
# Check if already exists (skip regen to save time/writes)
if os.path.exists(dest_path):
return (dest_path, duration)
self.logger.info("Generating Alert Tone...")
raw_filename = "raw_alert_tone.wav"
raw_path = os.path.join(self.tmp_dir, raw_filename)
# Generate Hi-Lo Siren: 0.25s High, 0.25s Low, repeated 3 times (total 4 cycles = 2s)
# 1000Hz and 800Hz
cmd = f"sox -n -r 8000 -c 1 {raw_path} synth 0.25 sine 1000 0.25 sine 800 repeat 3"
try:
subprocess.run(cmd, shell=True, check=True)
# Convert to GSM
gsm_tmp_path = os.path.join(self.tmp_dir, filename)
self.convert_audio(raw_path, gsm_tmp_path)
# Move to Asterisk Dir
move_cmd = f"sudo mv {gsm_tmp_path} {dest_path}"
subprocess.run(move_cmd, shell=True, check=True)
# Fix permissions
chmod_cmd = f"sudo chmod 644 {dest_path}"
subprocess.run(chmod_cmd, shell=True, check=True)
return (dest_path, duration)
except Exception as e:
self.logger.error(f"Failed to generate alert tone: {e}")
raise e

@ -0,0 +1,66 @@
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).
Checks 'source' config: 'fixed' or 'gpsd' (default).
If 'gpsd' fails, falls back to fixed config.
"""
loc_config = self.config.get('location', {})
source = loc_config.get('source', 'gpsd')
if source == 'fixed':
return self._get_fixed_coords()
if source == 'auto' or source == 'gpsd':
try:
return self._read_gpsd()
except Exception as e:
self.logger.warning(f"GPS read failed: {e}. Falling back to fixed/static.")
return self._get_fixed_coords()
# Default fallback
return self._get_fixed_coords()
def _get_fixed_coords(self) -> Tuple[float, float]:
loc_config = self.config.get('location', {})
# Try new 'fixed' block first
fixed = loc_config.get('fixed', {})
lat = fixed.get('lat')
lon = fixed.get('lon')
# Fallback to legacy top-level
if lat is None:
lat = loc_config.get('latitude')
if lon is None:
lon = loc_config.get('longitude')
if lat is None or lon is None:
raise ValueError("No valid location coordinates found in config (fixed or legacy) 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,351 @@
import argparse
import yaml
import time
import logging
import subprocess
from datetime import datetime
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 wait_for_asterisk(timeout=120):
"""
Polls Asterisk until it is ready to accept commands.
"""
logger.info("Waiting for Asterisk to be fully booted...")
start_time = time.time()
while (time.time() - start_time) < timeout:
try:
# Check if Asterisk is running and accepting CLI commands
# 'core waitfullybooted' ensures modules are loaded
subprocess.run("sudo /usr/sbin/asterisk -rx 'core waitfullybooted'",
shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
logger.info("Asterisk is ready.")
return True
except subprocess.CalledProcessError:
time.sleep(2)
logger.error("Timeout waiting for Asterisk.")
return False
def load_config(path):
with open(path, 'r') as f:
return yaml.safe_load(f)
def setup_logging(config):
"""
Configure logging based on config settings.
"""
level = logging.INFO
# If user wants debug, they can set it in config (not implemented yet, defaulting to INFO)
logging.basicConfig(
level=level,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Silence noisy libs if needed
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
def do_full_report(config):
loc_svc = LocationService(config)
lat, lon = loc_svc.get_coordinates()
# Provider
# Provider
prov_code = config.get('location', {}).get('provider')
provider = get_provider_instance(CountryCode=prov_code, Lat=lat, Lon=lon, Config=config)
# Auto-Timezone
source = config.get('location', {}).get('source', 'fixed')
cfg_tz = config.get('location', {}).get('timezone')
# Fetch Data
loc_info = provider.get_location_info(lat, lon)
# Auto-Timezone Logic
# 1. Prefer Provider's detected timezone (e.g. NWS provides it)
if loc_info.timezone and loc_info.timezone not in ['UTC', 'Unknown']:
logger.info(f"Using Provider Timezone: {loc_info.timezone}")
if 'location' not in config: config['location'] = {}
config['location']['timezone'] = loc_info.timezone
# 2. Fallback to Config
elif cfg_tz:
logger.info(f"Using Config Timezone: {cfg_tz}")
# 3. Last Resort: UTC (or maybe simple offset lookup if we implemented one)
else:
logger.warning("No timezone found! Defaulting to UTC.")
# Manual City Override
manual_city = config.get('location', {}).get('city')
if manual_city:
loc_info.city = manual_city
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(config)
text = narrator.build_full_report(loc_info, conditions, forecast, alerts, sun_info)
logger.info(f"Report Text: {text}")
# Audio
handler = AudioHandler(config)
wav_files = handler.generate_audio(text, "report.gsm")
# Play
nodes = config.get('voice', {}).get('nodes', [])
handler.play_on_nodes(wav_files, nodes)
def monitor_loop(config):
normal_interval = config.get('alerts', {}).get('check_interval_minutes', 10) * 60
current_interval = normal_interval
do_hourly = config.get('station', {}).get('hourly_report', False)
known_alerts = set()
# Initialize service objects
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(config)
handler = AudioHandler(config)
nodes = config.get('voice', {}).get('nodes', [])
# Initialize AlertReady (Optional)
ar_provider = None
if config.get('alerts', {}).get('enable_alert_ready', False):
try:
# Lazy import
from .provider.alert_ready import AlertReadyProvider
ar_provider = AlertReadyProvider(alerts=config.get('alerts', {}))
logger.info("AlertReady Provider Enabled.")
except Exception as e:
logger.error(f"Failed to load AlertReadyProvider: {e}")
logger.info(f"Starting Alert Monitor... (Hourly Reports: {do_hourly})")
# Robust Wait for Asterisk
if wait_for_asterisk():
# Give a small buffer after it claims ready
time.sleep(5)
try:
# Startup Announcement
# Resolve initial location info for city name
info = provider.get_location_info(lat, lon)
city_name = info.city if info.city else "Unknown Location"
interval_mins = int(normal_interval / 60)
startup_text = narrator.get_startup_message(city_name, interval_mins)
logger.info(f"Startup Announcement: {startup_text}")
# Generate and Play
wav_files = handler.generate_audio(startup_text, filename="startup.gsm")
if wav_files:
handler.play_on_nodes(wav_files, nodes)
except Exception as e:
logger.error(f"Failed to play startup announcement: {e}")
last_alert_check = 0
last_report_hour = -1
while True:
now = time.time()
now_dt = datetime.fromtimestamp(now)
# 1. Hourly Report Check
if do_hourly:
# Check if it is the top of the hour (minute 0) and we haven't played yet this hour
if now_dt.minute == 0 and now_dt.hour != last_report_hour:
logger.info("Triggering Hourly Weather Report...")
try:
do_full_report(config)
last_report_hour = now_dt.hour
except Exception as e:
logger.error(f"Hourly Report Failed: {e}")
# 2. Alert Check
if (now - last_alert_check) > current_interval:
last_alert_check = now
try:
logger.info(f"Checking for alerts... (Interval: {current_interval}s)")
# Fetch Weather Alerts
alerts = provider.get_alerts(lat, lon)
# Fetch AlertReady Alerts
if ar_provider:
try:
ar_alerts = ar_provider.get_alerts(lat, lon)
if ar_alerts:
logger.info(f"AlertReady found {len(ar_alerts)} items.")
alerts.extend(ar_alerts)
except Exception as e:
logger.error(f"AlertReady Check Failed: {e}")
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)
# Check for Tone Trigger & Dynamic Polling
tone_config = config.get('alerts', {}).get('alert_tone', {})
ranks = {'Unknown': 0, 'Advisory': 1, 'Watch': 2, 'Warning': 3, 'Critical': 4}
# 1. Determine Max Severity of ALL active alerts for Dynamic Polling
max_severity_rank = 0
for a in alerts: # Scan ALL active alerts
s_val = a.severity.value if hasattr(a.severity, 'value') else str(a.severity)
rank = ranks.get(s_val, 0)
if rank > max_severity_rank:
max_severity_rank = rank
# If Watch (2) or Higher, set interval to 1 minute
new_interval = normal_interval
active_threat = False
if max_severity_rank >= 2:
new_interval = 60
active_threat = True
logger.info("Active Watch/Warning/Critical detected. Requesting 1-minute polling.")
# Check for Interval Change
if new_interval != current_interval:
logger.info(f"Interval Change Detected: {current_interval} -> {new_interval}")
current_interval = new_interval
mins = int(current_interval / 60)
msg = narrator.get_interval_change_message(mins, active_threat)
logger.info(f"Announcing Interval Change: {msg}")
try:
wavs = handler.generate_audio(msg, "interval_change.gsm")
handler.play_on_nodes(wavs, nodes)
except Exception as e:
logger.error(f"Failed to announce interval change: {e}")
# 2. Check for Tone Trigger (New Alerts Only)
if new_alerts and tone_config.get('enabled', False):
min_sev_str = tone_config.get('min_severity', 'Warning')
threshold = ranks.get(min_sev_str, 3)
should_tone = False
for a in new_alerts:
s_val = a.severity.value if hasattr(a.severity, 'value') else str(a.severity)
if ranks.get(s_val, 0) >= threshold:
should_tone = True
break
if should_tone:
logger.info("High severity alert detected. Playing Attention Signal.")
try:
tone_file = handler.ensure_alert_tone()
handler.play_on_nodes([tone_file], nodes)
except Exception as e:
logger.error(f"Failed to play alert tone: {e}")
# Announce new items
if new_alerts:
logger.info(f"New Alerts detected: {len(new_alerts)}")
text = narrator.announce_alerts(new_alerts)
wavs = handler.generate_audio(text, "alert.gsm")
handler.play_on_nodes(wavs, nodes)
# Cleanup expired from known
known_alerts = known_alerts.intersection(current_ids)
except Exception as e:
logger.error(f"Monitor error: {e}")
# Check every minute (resolution of the loop)
time.sleep(60)
def do_test_alert(config):
"""
Executes the comprehensive Test Alert sequence.
"""
logger.info("Executing System Test Alert...")
narrator = Narrator(config)
handler = AudioHandler(config)
nodes = config.get('voice', {}).get('nodes', [])
if not nodes:
logger.error("No nodes configured for playback.")
return
# 1. Preamble
logger.info("Playing Preamble...")
preamble_text = narrator.get_test_preamble()
files = handler.generate_audio(preamble_text, "test_preamble.gsm")
handler.play_on_nodes(files, nodes)
# 2. Silence (10s unkeyed)
logger.info("Waiting 10s (Unkeyed)...")
time.sleep(10)
# 3. Alert Tone
# Check if tone is enabled in config, otherwise force it for test?
# User said "The preceding tone" in postamble, so we should play it regardless of config setting for the TEST mode.
logger.info("Playing Alert Tone...")
tone_file = handler.ensure_alert_tone()
handler.play_on_nodes([tone_file], nodes)
# 4. Test Message
logger.info("Playing Test Message...")
msg_text = narrator.get_test_message()
files = handler.generate_audio(msg_text, "test_message.gsm")
handler.play_on_nodes(files, nodes)
# 5. Postamble
logger.info("Playing Postamble...")
post_text = narrator.get_test_postamble()
files = handler.generate_audio(post_text, "test_postamble.gsm")
handler.play_on_nodes(files, nodes)
logger.info("Test Alert Concluded.")
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="Run immediate full weather report")
parser.add_argument("--monitor", action="store_true", help="Run in continuous monitor mode")
parser.add_argument("--test-alert", action="store_true", help="Run a comprehensive Test Alert sequence")
args = parser.parse_args()
config = load_config(args.config)
setup_logging(config) # Call the placeholder setup_logging
if args.test_alert:
do_test_alert(config)
sys.exit(0)
elif args.report:
do_full_report(config)
sys.exit(0)
elif args.monitor:
monitor_loop(config)
else:
parser.print_help()
if __name__ == "__main__":
main()

@ -0,0 +1,50 @@
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
issued: Optional[datetime] = None
@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
short_summary: Optional[str] = None
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,343 @@
from datetime import datetime
from typing import List
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
class Narrator:
def __init__(self, config: dict = None):
self.config = config or {}
def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str:
is_us = (loc.country_code or "").upper() == 'US'
# Temp Logic
temp_val = int(current.temperature)
temp_unit = "degrees celsius"
if is_us:
temp_val = int((current.temperature * 9/5) + 32)
temp_unit = "degrees" # US usually assumes F
wind = ""
# Low threshold for wind
if current.wind_speed is not None and current.wind_speed > 2:
direction = current.wind_direction
# Expand abbreviations
dirs = {
"N": "North", "NNE": "North Northeast", "NE": "Northeast", "ENE": "East Northeast",
"E": "East", "ESE": "East Southeast", "SE": "Southeast", "SSE": "South Southeast",
"S": "South", "SSW": "South Southwest", "SW": "Southwest", "WSW": "West Southwest",
"W": "West", "WNW": "West Northwest", "NW": "Northwest", "NNW": "North Northwest"
}
if direction and direction in dirs:
direction = dirs[direction]
# Wind Speed Logic
speed_val = int(current.wind_speed)
speed_unit = "kilometers per hour"
if is_us:
speed_val = int(current.wind_speed * 0.621371)
speed_unit = "miles per hour"
if direction:
wind = f", with winds from the {direction} at {speed_val} {speed_unit}"
else:
wind = f", with winds at {speed_val} {speed_unit}"
return (
f"Current conditions for {loc.city}, {loc.region}. "
f"The temperature is {temp_val} {temp_unit}. "
f"Conditions are {current.description}{wind}."
)
def announce_forecast(self, forecasts: List[WeatherForecast], loc: LocationInfo) -> str:
if not forecasts:
return ""
is_us = (loc.country_code or "").upper() == 'US'
style = self.config.get('station', {}).get('report_style', 'verbose')
text = "Here is the forecast. "
# New Logic per user request:
# Both modes use the concise "Quick" format (Short description + Temps).
# "quick" mode: Limit to Today + Tomorrow (first 4 periods usually: Today, Tonight, Tomorrow, Tomorrow Night).
# "verbose" mode: Full forecast duration (all periods).
limit = 4 if style == 'quick' else len(forecasts)
for f in forecasts[:limit]:
temp = ""
if f.high_temp is not None:
val = f.high_temp
if is_us: val = (val * 9/5) + 32
temp = f" High {int(val)}."
elif f.low_temp is not None:
val = f.low_temp
if is_us: val = (val * 9/5) + 32
temp = f" Low {int(val)}."
# Always prefer short_summary if available for the new style
condition = f.short_summary if f.short_summary else f.summary
text += f"{f.period_name}: {condition}.{temp} Break. [PAUSE] "
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:
# Format times
fmt = "%I %M %p"
issued_str = a.issued.strftime(fmt) if a.issued else ""
eff_str = a.effective.strftime(fmt)
exp_str = a.expires.strftime(fmt)
# Construct message
# "A Tornado Warning was issued at 3 00 PM. Effective from 3 15 PM until 4 00 PM."
item_text = f"A {a.title} "
if issued_str:
item_text += f"was issued at {issued_str}. "
else:
item_text += "is in effect. "
# If effective is different from issued, mention it
if a.effective and a.issued and abs((a.effective - a.issued).total_seconds()) > 600: # >10 mins diff
item_text += f"It is effective from {eff_str}. "
# Expires
# Check if expires is dummy (e.g. for EC) or real
# For now, just announce it if it's in the future or we assume it's valid
item_text += f"It expires at {exp_str}. "
text += item_text
# Add sign-off to alerts
callsign = self.config.get('station', {}).get('callsign')
if callsign:
text += f" {callsign} Clear."
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"
import pytz
tz_name = self.config.get('location', {}).get('timezone', 'UTC')
try:
tz = pytz.timezone(tz_name)
now = datetime.now(tz)
except Exception:
now = datetime.now()
now_str = 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:]
# State Expansion Map
states_map = {
"AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California",
"CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "FL": "Florida", "GA": "Georgia",
"HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa",
"KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
"MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", "MO": "Missouri",
"MT": "Montana", "NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey",
"NM": "New Mexico", "NY": "New York", "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio",
"OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina",
"SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont",
"VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming",
"DC": "District of Columbia"
}
region_clean = loc.region.upper() if loc.region else ""
region_full = states_map.get(region_clean, loc.region) # Default to original if no match (e.g. "British Columbia")
# Intro
callsign = self.config.get('station', {}).get('callsign')
full_callsign = callsign or ""
if callsign:
# Cross-border logic
suffix = ""
upper_call = callsign.upper()
country = loc.country_code.upper() if loc.country_code else ""
# US Ops (N, K, W) in Canada (CA) -> "Portable VE..."
if country == 'CA' and (upper_call.startswith('N') or upper_call.startswith('K') or upper_call.startswith('W')):
# default fallback
ve_prefix = "V E"
# Map full province names (returned by EC provider) to prefixes
# Note: keys must match what env_canada returns (Title Case generally)
prov_map = {
"Nova Scotia": "V E 1",
"Quebec": "V E 2",
"Québec": "V E 2", # Handle accent
"Ontario": "V E 3",
"Manitoba": "V E 4",
"Saskatchewan": "V E 5",
"Alberta": "V E 6",
"British Columbia": "V E 7",
"Northwest Territories": "V E 8",
"New Brunswick": "V E 9",
"Newfoundland": "V O 1",
"Labrador": "V O 2",
"Yukon": "V Y 1",
"Prince Edward Island": "V Y 2",
"Nunavut": "V Y 0"
}
# Try to find province in region string
region_title = loc.region.title() if loc.region else ""
# Direct match or partial match? EC usually returns clean names.
# However, let's check if the region is in our keys
if region_title in prov_map:
ve_prefix = prov_map[region_title]
else:
# Try code lookup if region was passed as code (e.g. AB, ON)
code_map = {
"NS": "V E 1", "QC": "V E 2", "ON": "V E 3", "MB": "V E 4",
"SK": "V E 5", "AB": "V E 6", "BC": "V E 7", "NT": "V E 8",
"NB": "V E 9", "NL": "V O 1", "YT": "V Y 1", "PE": "V Y 2", "NU": "V Y 0"
}
if loc.region.upper() in code_map:
ve_prefix = code_map[loc.region.upper()]
suffix = f" Portable {ve_prefix}"
# Canadian Ops (V) in US -> "Portable W..."
elif country == 'US' and upper_call.startswith('V'):
us_prefix = "W" # Fallback
# Map States to US Call Regions (Source: provided file)
# W0: CO, IA, KS, MN, MO, NE, ND, SD
# W1: CT, ME, MA, NH, RI, VT
# W2: NJ, NY
# W3: DE, DC, MD, PA
# W4: AL, FL, GA, KY, NC, SC, TN, VA
# W5: AR, LA, MS, NM, OK, TX
# W6: CA
# W7: AZ, ID, MT, NV, OR, UT, WA, WY
# W8: MI, OH, WV
# W9: IL, IN, WI
# WH6: HI, WL7: AK, WP4: PR, WP2: VI, WH2: GU, WH0: MP, WH8: AS
zone_map = {
"Colorado": "W 0", "Iowa": "W 0", "Kansas": "W 0", "Minnesota": "W 0",
"Missouri": "W 0", "Nebraska": "W 0", "North Dakota": "W 0", "South Dakota": "W 0",
"Connecticut": "W 1", "Maine": "W 1", "Massachusetts": "W 1", "New Hampshire": "W 1",
"Rhode Island": "W 1", "Vermont": "W 1",
"New Jersey": "W 2", "New York": "W 2",
"Delaware": "W 3", "District of Columbia": "W 3", "Maryland": "W 3", "Pennsylvania": "W 3",
"Alabama": "W 4", "Florida": "W 4", "Georgia": "W 4", "Kentucky": "W 4",
"North Carolina": "W 4", "South Carolina": "W 4", "Tennessee": "W 4", "Virginia": "W 4",
"Arkansas": "W 5", "Louisiana": "W 5", "Mississippi": "W 5", "New Mexico": "W 5",
"Oklahoma": "W 5", "Texas": "W 5",
"California": "W 6",
"Arizona": "W 7", "Idaho": "W 7", "Montana": "W 7", "Nevada": "W 7",
"Oregon": "W 7", "Utah": "W 7", "Washington": "W 7", "Wyoming": "W 7",
"Michigan": "W 8", "Ohio": "W 8", "West Virginia": "W 8",
"Illinois": "W 9", "Indiana": "W 9", "Wisconsin": "W 9",
"Hawaii": "W H 6", "Alaska": "W L 7", "Puerto Rico": "W P 4", "Virgin Islands": "W P 2",
"Guam": "W H 2", "American Samoa": "W H 8"
}
region_title = region_full.title() if region_full else ""
# Try full name match
if region_title in zone_map:
us_prefix = zone_map[region_title]
else:
pass
suffix = f" Portable {us_prefix}"
full_callsign = f"{callsign}{suffix}"
parts.append(f"CQ CQ CQ. This is {full_callsign} with the updated weather report.")
else:
parts.append("Good day.")
# State Expansion (Done above)
parts.append(f"The time is {now_str}.")
parts.append(f"This is the automated weather report for {loc.city}, {region_full}. Break. [PAUSE]")
if alerts:
parts.append("Please be advised: " + self.announce_alerts(alerts) + " Break. [PAUSE]")
parts.append(self.announce_conditions(loc, current) + " Break. [PAUSE]")
parts.append(self.announce_forecast(forecast, loc))
if sun_info:
parts.append(sun_info + " Break. [PAUSE]")
# Outro / Sign-off
# "This concludes the weather report for (Location) (Callsign) Clear"
# The forecast loop ends with a Break/[PAUSE] already, so this will be a new segment.
parts.append(f"This concludes the weather report for {loc.city}. {full_callsign} Clear.")
return " ".join(parts)
def get_test_preamble(self) -> str:
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
return (
f"{callsign} Testing. The following is an alerting test of an automated alerting notification and message. "
"Do not take any action as a result of the following message. "
"Repeating - take no action - this is only a test. "
"The test tones will follow in 10 seconds."
)
def get_test_message(self) -> str:
return (
"This is a test message. "
"This is a sample emergency test message. "
"This is only a test. No action is required."
)
def get_test_postamble(self) -> str:
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
return (
f"{callsign} reminds you this was a test. "
"Do not take action. Repeat - take no action. "
"If this was an actual emergency, the preceding tone would be followed by specific information "
"from official authorities on an imminent emergency that requires your immediate attention "
"and possibly action to prevent loss of life, injury or property damage. "
"This test is concluded."
)
def get_startup_message(self, city: str, interval: int) -> str:
callsign = self.config.get('station', {}).get('callsign', 'Station')
# "N7XOB Alerting Notification Active. Current location Quebec City. Checking interval is 10 minutes."
return (
f"{callsign} Alerting Notification Active. "
f"Current location {city}. "
f"Checking interval is {interval} minutes."
)
def get_interval_change_message(self, interval_mins: int, active_alerts: bool) -> str:
callsign = self.config.get('station', {}).get('callsign', 'Station')
if active_alerts:
return f"{callsign} Notification. The monitoring interval is being changed to {interval_mins} minute{'s' if interval_mins!=1 else ''} due to active alerts in the area."
else:
return f"{callsign} Notification. Active alerts have expired. The monitoring interval is being changed back to {interval_mins} minutes."

@ -0,0 +1,190 @@
import requests
import logging
import xml.etree.ElementTree as ET
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
logger = logging.getLogger(__name__)
class AlertReadyProvider(WeatherProvider):
# Public NAAD Feed URL (Root)
# Browsable at: http://cap.naad-adna.pelmorex.com/
# We use the aggregate RSS feed for all of Canada.
DEFAULT_FEED_URL = "http://cap.naad-adna.pelmorex.com/rss/all.rss"
def __init__(self, **kwargs):
self.points_cache = {}
# Hardcoded URL as requested, user just enables the feature.
self.feed_url = self.DEFAULT_FEED_URL
self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', [])
# reuse ca_events or have separate list? Using ca_events is consistent.
self.allowed_events = kwargs.get('alerts', {}).get('ca_events', [])
def get_location_info(self, lat: float, lon: float) -> LocationInfo:
# Not used for location resolution, just alerts
return LocationInfo(latitude=lat, longitude=lon, city="Unknown", region="Canada", country_code="CA", timezone="UTC")
def get_conditions(self, lat: float, lon: float) -> CurrentConditions:
# Not supported
return CurrentConditions(temperature=0, description="N/A")
def get_forecast(self, lat: float, lon: float) -> List[WeatherForecast]:
return []
def is_point_in_polygon(self, x, y, poly):
"""
Ray Casting Algorithm.
x: lon, y: lat
poly: list of (lon, lat) tuples
"""
n = len(poly)
inside = False
p1x, p1y = poly[0]
for i in range(n + 1):
p2x, p2y = poly[i % n]
if y > min(p1y, p2y):
if y <= max(p1y, p2y):
if x <= max(p1x, p2x):
if p1y != p2y:
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x <= xinters:
inside = not inside
p1x, p1y = p2x, p2y
return inside
def parse_cap_polygon(self, poly_str: str) -> List[tuple]:
# "lat,lon lat,lon ..."
points = []
try:
for pair in poly_str.strip().split(' '):
if ',' in pair:
lat_str, lon_str = pair.split(',')
points.append((float(lon_str), float(lat_str)))
except ValueError:
pass
return points
def get_alerts(self, lat: float, lon: float) -> List[WeatherAlert]:
alerts = []
try:
# parsing the RSS/Atom feed of *current* CAP messages
# Note: The NAAD feed is huge. We should optimize?
# User wants "non-weather". Typically much fewer than weather.
# But the feed includes EVERYTHING (Environment Canada too?).
# Yes, "all.rss".
# We must be careful not to duplicate what EC provider provides.
# Usually EC provider handles "Weather" types.
# We should filter for types NOT in a blacklist or ONLY in a whitelist.
# For now, let's fetch and see.
resp = requests.get(self.feed_url, timeout=10)
if resp.status_code != 200:
logger.warning(f"AlertReady Feed fetch failed: {resp.status_code}")
return []
# Simple XML parsing of RSS
root = ET.fromstring(resp.content)
# RSS 2.0: channel/item
for item in root.findall('./channel/item'):
title = item.find('title').text if item.find('title') is not None else "Unknown Alert"
link = item.find('link').text if item.find('link') is not None else None
# Check duplication? (Maybe check Title for 'Environment Canada')
# If title contains "Environment Canada", skip?
# User config says "enable_alert_ready" for "Civil/Non-Weather".
# Let's filter OUT known weather sources if possible, or just rely on self.allowed_events?
# But self.allowed_events currently holds Weather strings too (for EC provider).
# The user config seems to share "ca_events".
# If we use shared config, we double-report.
# Heuristic: If Title starts with "Environment Canada", skip it (let EC provider handle it).
if "Environment Canada" in title:
continue
if not link:
continue
# Fetch CAP XML
try:
cap_resp = requests.get(link, timeout=5)
if cap_resp.status_code != 200:
continue
cap_root = ET.fromstring(cap_resp.content)
# Namespace handling might be needed. CAP usually uses xmlns "urn:oasis:names:tc:emergency:cap:1.2"
# We can strip namespaces or use wildcards.
# ElementTree with namespaces is annoying.
# Hack: Remove xmlns from string before parsing? Or just use local-name().
# Let's try ignoring namespace by using `findall` with `{namespace}tag` if needed,
# but simple `find` might fail.
# Let's rely on string searching or simple iterative search if needed.
# Finding <info> block
# Assume namespace present.
ns = {'cap': 'urn:oasis:names:tc:emergency:cap:1.2'}
# Try finding info without NS first (if no xmlns defined), then with.
infos = cap_root.findall('cap:info', ns)
if not infos:
infos = cap_root.findall('info') # Try no namespace
for info in infos:
# Event Type
event = info.find('cap:event', ns).text if info.find('cap:event', ns) is not None else info.find('event').text
# Severity
severity_str = info.find('cap:severity', ns).text if info.find('cap:severity', ns) is not None else info.find('severity').text
# Area/Polygon
areas = info.findall('cap:area', ns)
if not areas:
areas = info.findall('area')
is_relevant = False
for area in areas:
polys = area.findall('cap:polygon', ns)
if not polys: polys = area.findall('polygon')
for poly_elem in polys:
poly_points = self.parse_cap_polygon(poly_elem.text)
if poly_points and self.is_point_in_polygon(lon, lat, poly_points):
is_relevant = True
break
if is_relevant: break
if is_relevant:
# Parse dates
effective = None
expires = None
issued = None
# ... (Date parsing logic similar to other providers)
# Map severity
sev_enum = AlertSeverity.UNKNOWN
if severity_str == 'Extreme': sev_enum = AlertSeverity.CRITICAL
elif severity_str == 'Severe': sev_enum = AlertSeverity.WARNING
elif severity_str == 'Moderate': sev_enum = AlertSeverity.WATCH
elif severity_str == 'Minor': sev_enum = AlertSeverity.ADVISORY
# Create Alert
# Deduplicate ID? Link is unique usually.
alert = WeatherAlert(
id=link, # Use URL as ID
title=event,
description=info.find('cap:description', ns).text if info.find('cap:description', ns) is not None else "No description",
severity=sev_enum,
issued=datetime.now(), # Placeholder if parsing fails
effective=datetime.now(),
expires=datetime.now()
)
alerts.append(alert)
except Exception as e:
logger.warning(f"Failed to process CAP {link}: {e}")
continue
except Exception as e:
logger.error(f"AlertReady Error: {e}")
return alerts

@ -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,151 @@
from datetime import datetime
from typing import List
from env_canada.ec_weather import ECWeather
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', [])
self.allowed_events = kwargs.get('alerts', {}).get('ca_events', [])
def _get_ec_data(self, lat, lon):
# ECWeather auto-selects station based on lat/lon
import asyncio
ec = ECWeather(coordinates=(lat, lon))
asyncio.run(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
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f"DEBUG: ec.metadata type: {type(meta)}")
logger.info(f"DEBUG: ec.metadata content: {meta}")
# Safe access
if not isinstance(meta, dict):
meta = {}
city = meta.get('location')
region = meta.get('province')
# Fallback to reverse_geocoder if EC fails to name the location
if not city or city == 'Unknown':
try:
import reverse_geocoder as rg
results = rg.search((lat, lon))
if results:
city = results[0].get('name')
if not region:
region = results[0].get('admin1')
except Exception as e:
logger.warning(f"Reverse geocode failed: {e}")
return LocationInfo(
latitude=lat,
longitude=lon,
city=city or 'Unknown',
region=region or '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') or 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', {}).get('value') or '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', ''),
short_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(ECWeather(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
import asyncio
asyncio.run(ec.update())
for a in ec.alerts:
title = a.get('title', '')
if title in seen_titles: continue
seen_titles.add(title)
# Filter by allowed list if present
if self.allowed_events:
if not any(ae.lower() in title.lower() for ae in self.allowed_events):
continue
# 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_str = a.get('date')
eff_dt = now
if eff_str:
# EC format often: "2023-10-27T15:00:00-04:00" or similar?
# actually env_canada lib might return a string.
# We'll try to parse it if dateutil is available, or use now.
try:
from dateutil.parser import parse as parse_date
eff_dt = parse_date(eff_str)
except:
pass
results.append(WeatherAlert(
id=title,
severity=severity,
title=title,
description=a.get('detail', ''),
area_description=ec.metadata.get('location', 'Local Area'),
effective=eff_dt,
expires=now, # EC doesn't explicitly give expires in this view
issued=eff_dt
))
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,157 @@
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', [])
self.allowed_events = kwargs.get('alerts', {}).get('us_events', [])
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')
h_val = props.get('relativeHumidity', {}).get('value')
return CurrentConditions(
temperature=temp_c if temp_c is not None else 0.0,
humidity=int(h_val) if h_val is not None else None,
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'),
short_summary=p.get('shortForecast')
))
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()
# Filter by allowed list if present
if self.allowed_events:
# Check if any allowed string is in the event title (case-insensitive)
# "Tornado Warning" in "Tornado Warning" -> True
if not any(a.lower() in props['event'].lower() for a in self.allowed_events):
continue
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']),
issued=parse_date(props['sent']) if props.get('sent') else None
))
return alerts

@ -0,0 +1,58 @@
location:
source: fixed # Valid values: 'fixed', 'gpsd', 'auto'
latitude: 0.0000
longitude: 0.0000
# city: "Your City"
timezone: "America/New_York"
# provider: US # Optional: force US or CA
station:
# Station ID for announcements
callsign: "MYCALL"
# Report Style: 'verbose' (Full sentences) or 'quick' (Short summary + High/Low)
report_style: "quick"
hourly_report: true # Play full report at the top of every hour
voice:
# Example utilizing pico2wave (sudo apt install libttspico-utils)
tts_command: 'pico2wave -w {file} "{text}"'
nodes:
- "1000"
- "2000"
alerts:
check_interval_minutes: 10
# Minimum Severity to Report. Options:
# - "Advisory", "Watch", "Warning", "Critical"
min_severity: "Watch"
# Attention Signal (2-second alternating tone) before Warnings/Critical alerts
alert_tone:
enabled: true
min_severity: "Warning"
# Optional: List of NWS Zones/Counties to monitor in addition to current location
# extra_zones:
# - "VAC001"
# ==========================================
# CANADA SETTINGS
# ==========================================
# Enable separate scraping of the NAAD System for Civil/Non-Weather events
enable_alert_ready: true
# --- Environment Canada Event Filters (See full list in defaults) ---
ca_events:
- "Tornado Warning"
- "Severe Thunderstorm Warning"
# ... (Add monitored events here)
# ==========================================
# UNITED STATES SETTINGS
# ==========================================
# Filter specific US event types (Case Insensitive Partial Match)
us_events:
- "Tornado Warning"
- "Severe Thunderstorm Warning"
# ... (Add monitored events here)

@ -0,0 +1,26 @@
location:
type: auto # or static
# latitude: 45.4215
# longitude: -75.6972
# Source: gpsd or fixed
source: gpsd
# Fixed location (used if source is 'fixed' or gpsd fails)
fixed:
lat: 44.0
lon: -73.0
provider: CA # CA for env_canada, US for nws
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,12 @@
location:
source: fixed
fixed:
lat: 45.4215
lon: -75.6972
provider: CA
voice:
tts_command: "pico2wave -w {file} \"{text}\""
audio:
nodes: ['62394']
alerts:
check_interval_minutes: 15

@ -0,0 +1,18 @@
from env_canada import ECData
import json
lat = 44.0
lon = -73.0 # Using my test coords from config.yaml.example or config.yaml?
# config.yaml has 45.4215, -75.6972 for Ottawa.
# Wait, my config_fixed.yaml had:
# lat: 45.4215
# lon: -75.6972
# I will test with those.
try:
ec = ECData(coordinates=(45.4215, -75.6972))
ec.update()
print(f"Type of metadata: {type(ec.metadata)}")
print(f"Metadata content: {ec.metadata}")
except Exception as e:
print(f"Error: {e}")

@ -0,0 +1,30 @@
/home/admin/asl3_wx_announce/asl3_wx_announce/provider/ec.py:15: RuntimeWarning: coroutine 'ECWeather.update' was never awaited
ec.update()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata type: <class 'env_canada.ec_weather.MetaData'>
INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata content: MetaData(attribution='Data provided by Environment Canada', timestamp=datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), station=None, location=None, cache_returned_on_update=0, last_update_error='')
INFO:asl3_wx:Resolved Location: Unknown, Canada (CA)
/home/admin/asl3_wx_announce/asl3_wx_announce/provider/ec.py:86: RuntimeWarning: coroutine 'ECWeather.update' was never awaited
ec.update()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
INFO:asl3_wx:Report Text: Good day. The time is 8 06 PM. This is the automated weather report for Unknown. Current conditions for Unknown, Canada. The temperature is 0 degrees celsius, 32 degrees fahrenheit. Conditions are Unknown.
INFO:asl3_wx_announce.audio:Generating audio: pico2wave -w /tmp/asl3_wx/report.ul "Good day. The time is 8 06 PM. This is the automated weather report for Unknown. Current conditions for Unknown, Canada. The temperature is 0 degrees celsius, 32 degrees fahrenheit. Conditions are Unknown. "
Cannot open output wave file
ERROR:asl3_wx_announce.audio:TTS Failed: Command 'pico2wave -w /tmp/asl3_wx/report.ul "Good day. The time is 8 06 PM. This is the automated weather report for Unknown. Current conditions for Unknown, Canada. The temperature is 0 degrees celsius, 32 degrees fahrenheit. Conditions are Unknown. "' returned non-zero exit status 1.
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 109, in <module>
main()
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 102, in main
do_full_report(config)
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 46, in do_full_report
wav_file = handler.generate_audio(text, "report.ul")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/admin/asl3_wx_announce/asl3_wx_announce/audio.py", line 43, in generate_audio
raise e
File "/home/admin/asl3_wx_announce/asl3_wx_announce/audio.py", line 32, in generate_audio
subprocess.run(cmd, shell=True, check=True)
File "/usr/lib/python3.11/subprocess.py", line 571, in run
raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command 'pico2wave -w /tmp/asl3_wx/report.ul "Good day. The time is 8 06 PM. This is the automated weather report for Unknown. Current conditions for Unknown, Canada. The temperature is 0 degrees celsius, 32 degrees fahrenheit. Conditions are Unknown. "' returned non-zero exit status 1.

@ -0,0 +1,45 @@
# Deploy ASL3 WX Announce to Remote Node
# USAGE: .\deploy.ps1
# --- CONFIGURATION (PLEASE EDIT THESE) ---
$REMOTE_HOST = "192.168.50.61" # REPLACE with your Node's IP address
$REMOTE_USER = "admin" # REPLACE with your SSH username (e.g. repeater, root, admin)
$REMOTE_PATH = "/opt/asl3_wx_announce" # Standard location
# -----------------------------------------
Write-Host "Deploying to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}..." -ForegroundColor Cyan
# 1. Create a local tar archive of the files to minimise SCP calls (and password prompts)
Write-Host "Bundling files..."
$TAR_FILE = "deploy_package.tar"
tar -cvf $TAR_FILE asl3_wx_announce requirements.txt config.yaml diagnose_audio.py asl3-wx.service
if ($LASTEXITCODE -ne 0) {
Write-Host "Error creating tar archive." -ForegroundColor Red
exit
}
# 2. Upload the tar archive (Prompt #1)
Write-Host "Uploading package (You may be asked for your password)..."
scp $TAR_FILE ${REMOTE_USER}@${REMOTE_HOST}:/tmp/$TAR_FILE
if ($LASTEXITCODE -ne 0) {
Write-Host "SCP failed. Check connection." -ForegroundColor Red
exit
}
# 3. Extract on remote server (Prompt #2)
Write-Host "Extracting on remote server (You may be asked for your password again)..."
# Changed to use sudo for /opt/ access
# Also chown to user so they can run without sudo if desired, or keep root.
# Service runs as root usually.
$REMOTE_CMD = "sudo mkdir -p ${REMOTE_PATH} && sudo tar -xvf /tmp/${TAR_FILE} -C ${REMOTE_PATH} && rm /tmp/${TAR_FILE} && sudo pip3 install --break-system-packages -r ${REMOTE_PATH}/requirements.txt && sudo cp ${REMOTE_PATH}/asl3-wx.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable asl3-wx && sudo systemctl restart asl3-wx"
ssh ${REMOTE_USER}@${REMOTE_HOST} $REMOTE_CMD
# 4. Cleanup local artifact
Remove-Item $TAR_FILE
Write-Host "Deployment Complete!" -ForegroundColor Green
Write-Host "To run the code:"
Write-Host " ssh ${REMOTE_USER}@${REMOTE_HOST}"
Write-Host " cd ${REMOTE_PATH}"
Write-Host " sudo python3 -m asl3_wx_announce.main"

@ -0,0 +1,91 @@
import subprocess
import os
import sys
import shutil
# Configuration
TEST_NODE = "62394" # User confirmed this node
TONE_FILE = "/tmp/test_tone.wav"
FINAL_TONE_FILE = "/var/lib/asterisk/sounds/test_tone.wav"
TTS_FILE = "/tmp/test_tts.wav"
FINAL_TTS_FILE = "/var/lib/asterisk/sounds/test_tts.wav"
def run_cmd(cmd, description):
print(f"[{description}] Executing: {cmd}")
try:
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
return True, result.stdout
except subprocess.CalledProcessError as e:
print(f"FAILED: {e}")
print(f"STDOUT: {e.stdout}")
print(f"STDERR: {e.stderr}")
return False, e.stderr
def main():
print("=== ASL3 Audio Diagnostic ===")
# 1. Check Tools
print("\n--- Checking Tools ---")
tools = ["sox", "pico2wave", "asterisk"]
for tool in tools:
if shutil.which(tool) or os.path.exists(f"/usr/sbin/{tool}") or os.path.exists(f"/usr/bin/{tool}"):
print(f"OK: {tool} found")
else:
print(f"WARNING: {tool} not found in PATH")
# 2. Test Tone Generation (Isolates TTS)
print("\n--- Test 1: Pure Tone Generation ---")
# Generate 1kHz sine wave for 3 seconds
cmd = f"sox -n -r 8000 -c 1 -b 16 -e signed-integer {TONE_FILE} synth 3 sine 1000"
success, _ = run_cmd(cmd, "Generating Tone")
if success:
if os.path.getsize(TONE_FILE) > 0:
print(f"OK: Tone file created ({os.path.getsize(TONE_FILE)} bytes)")
else:
print("FAIL: Tone file is empty")
return
# Move to Asterisk Sounds
run_cmd(f"sudo mv {TONE_FILE} {FINAL_TONE_FILE}", "Installing Tone File")
run_cmd(f"sudo chmod 644 {FINAL_TONE_FILE}", "Setting Permissions")
# Playback
print(f"Attempting playback on node {TEST_NODE}...")
play_cmd = f"sudo /usr/sbin/asterisk -rx 'rpt playback {TEST_NODE} test_tone'"
p_success, _ = run_cmd(play_cmd, "Playing Tone")
if p_success:
print(">>> LISTEN NOW: You should hear a 3-second beep.")
else:
print("FAIL: Playback command failed")
input("\nPress Enter to continue to TTS test (or Ctrl+C to stop)...")
# 3. Test TTS Generation
print("\n--- Test 2: TTS Generation ---")
text = "Audio test. One two three."
cmd = f"pico2wave -w {TTS_FILE} \"{text}\""
success, _ = run_cmd(cmd, "Generating TTS")
if success:
# Check size
if os.path.exists(TTS_FILE) and os.path.getsize(TTS_FILE) > 100:
print(f"OK: TTS file created ({os.path.getsize(TTS_FILE)} bytes)")
# Convert
conv_cmd = f"sox {TTS_FILE} -r 8000 -c 1 -b 16 -e signed-integer {FINAL_TTS_FILE}"
run_cmd(conv_cmd, "Converting & Installing TTS")
run_cmd(f"sudo chmod 644 {FINAL_TTS_FILE}", "Permissions")
# Playback
print(f"Attempting playback on node {TEST_NODE}...")
play_cmd = f"sudo /usr/sbin/asterisk -rx 'rpt playback {TEST_NODE} test_tts'"
run_cmd(play_cmd, "Playing TTS")
print(">>> LISTEN NOW: You should hear 'Audio test, one two three'.")
else:
print("FAIL: TTS file missing or too small")
print("\n=== Diagnostic Complete ===")
if __name__ == "__main__":
main()

@ -0,0 +1,22 @@
W0 Colorado, Iowa, Kansas, Minnesota
Missouri,Nebraska,North-Dakota,South-Dakota
W1 Connecticut, Maine, Massachusetts
New-Hampshire,Rhode-Island,Vermont
W2 New-Jersey, New-York
W3 Delaware, D.C., Maryland, Pennsylvania
W4 Alabama, Florida,Georgia,Kentucky
North-Carolina, South-Carolina, Tennessee,Virginia
W5 Arkansas , Louisiana , Mississippi
New-Mexico , Oklahoma , Texas
W6 California
W7 Arizona , Idaho , Montana , Nevada
Oregon , Utah, Washington , Wyoming
W8 Michigan , Ohio , West-Virginia
W9 Illinois , Indiana , WisconsinWH0 Mariana-Is
WH2 Guam
WH6 Hawaii
WH8 American Samoa
WL7 Alaska
WP2 Virgin Islands
WP4 Puerto Rico

@ -0,0 +1,85 @@
Deployment Complete!
To run the code:
ssh admin@192.168.50.61
cd /opt/asl3_wx_announce
sudo python3 -m asl3_wx_announce.main
PS C:\Users\eric\.gemini\antigravity\scratch\Alert ASL> ssh admin@192.168.50.61
Enter passphrase for key 'C:\Users\eric/.ssh/id_ed25519':
Linux node92394 6.12.62+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.62-1+rpt1~bookworm (2026-01-19) aarch64
Welcome to AllStarLink v3
* A CLI menu is accessible by typing 'sudo asl-menu'
* The Asterisk CLI is accessible by typing 'sudo asterisk -rv'
* An online manual is located at https://allstarlink.github.io
* Package updates are obtained through the Cockpit web
console or by typing 'sudo apt update && sudo apt upgrade -y'
* This system uses firewalld. Ports are controlled in the
Cockpit web console or by using the command:
'sudo firewall-cmd -p PORT:TYPE'
* For help, visit https://community.allstarlink.org
Web console: https://node92394:9090/ or https://192.168.50.61:9090/
Last login: Thu Jan 29 01:39:35 2026 from 192.168.50.1
admin@node92394:~ $ sudo reboot
Broadcast message from root@node92394 on pts/1 (Thu 2026-01-29 01:43:05 GMT):
The system will reboot now!
admin@node92394:~ $ Connection to 192.168.50.61 closed by remote host.
Connection to 192.168.50.61 closed.
PS C:\Users\eric\.gemini\antigravity\scratch\Alert ASL> ssh admin@192.168.50.61 "sudo journalctl -u asl3-wx -n 50 --no-pager"
Enter passphrase for key 'C:\Users\eric/.ssh/id_ed25519':
Jan 29 02:01:01 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 2 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_2"
Jan 29 02:01:01 node92394 sudo[2232]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 1966 asl3_wx_report_2'
Jan 29 02:01:01 node92394 sudo[2232]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:01 node92394 sudo[2232]: pam_unix(sudo:session): session closed for user root
Jan 29 02:01:01 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 2 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_2"
Jan 29 02:01:01 node92394 sudo[2237]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 62394 asl3_wx_report_2'
Jan 29 02:01:01 node92394 sudo[2237]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:01 node92394 sudo[2237]: pam_unix(sudo:session): session closed for user root
Jan 29 02:01:24 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Pausing 5s for unkey...
Jan 29 02:01:29 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Segment 3 duration: 22.544s
Jan 29 02:01:29 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 3 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_3"
Jan 29 02:01:29 node92394 sudo[2280]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 1966 asl3_wx_report_3'
Jan 29 02:01:29 node92394 sudo[2280]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:29 node92394 sudo[2280]: pam_unix(sudo:session): session closed for user root
Jan 29 02:01:29 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 3 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_3"
Jan 29 02:01:29 node92394 sudo[2285]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 62394 asl3_wx_report_3'
Jan 29 02:01:29 node92394 sudo[2285]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:29 node92394 sudo[2285]: pam_unix(sudo:session): session closed for user root
Jan 29 02:01:53 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Pausing 5s for unkey...
Jan 29 02:01:58 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Segment 4 duration: 14.732s
Jan 29 02:01:58 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 4 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_4"
Jan 29 02:01:58 node92394 sudo[2292]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 1966 asl3_wx_report_4'
Jan 29 02:01:58 node92394 sudo[2292]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:58 node92394 sudo[2292]: pam_unix(sudo:session): session closed for user root
Jan 29 02:01:58 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 4 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_4"
Jan 29 02:01:58 node92394 sudo[2297]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 62394 asl3_wx_report_4'
Jan 29 02:01:58 node92394 sudo[2297]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:01:58 node92394 sudo[2297]: pam_unix(sudo:session): session closed for user root
Jan 29 02:02:14 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Pausing 5s for unkey...
Jan 29 02:02:19 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Segment 5 duration: 8.164s
Jan 29 02:02:19 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 5 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_5"
Jan 29 02:02:19 node92394 sudo[2304]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 1966 asl3_wx_report_5'
Jan 29 02:02:19 node92394 sudo[2304]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:02:19 node92394 sudo[2304]: pam_unix(sudo:session): session closed for user root
Jan 29 02:02:19 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 5 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_5"
Jan 29 02:02:19 node92394 sudo[2309]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 62394 asl3_wx_report_5'
Jan 29 02:02:19 node92394 sudo[2309]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:02:19 node92394 sudo[2309]: pam_unix(sudo:session): session closed for user root
Jan 29 02:02:29 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Pausing 5s for unkey...
Jan 29 02:02:34 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Segment 6 duration: 6.16s
Jan 29 02:02:34 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 6 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_6"
Jan 29 02:02:34 node92394 sudo[2316]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 1966 asl3_wx_report_6'
Jan 29 02:02:34 node92394 sudo[2316]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:02:34 node92394 sudo[2316]: pam_unix(sudo:session): session closed for user root
Jan 29 02:02:34 node92394 python3[1195]: INFO:asl3_wx_announce.audio:Playing segment 6 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_6"
Jan 29 02:02:34 node92394 sudo[2321]: root : PWD=/opt/asl3_wx_announce ; USER=root ; COMMAND=/usr/sbin/asterisk -rx 'rpt playback 62394 asl3_wx_report_6'
Jan 29 02:02:34 node92394 sudo[2321]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=0)
Jan 29 02:02:34 node92394 sudo[2321]: pam_unix(sudo:session): session closed for user root
Jan 29 02:04:42 node92394 python3[1195]: INFO:asl3_wx:Checking for alerts...
Jan 29 02:04:44 node92394 python3[1195]: ERROR:asl3_wx_announce.provider.alert_ready:AlertReady Error: HTTPConnectionPool(host='cap.naad-adna.pelmorex.com', port=80): Max retries exceeded with url: /rss/all.rss (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x7f7067e550>: Failed to establish a new connection: [Errno -2] Name or service not known'))
PS C:\Users\eric\.gemini\antigravity\scratch\Alert ASL>

@ -0,0 +1,11 @@
admin@node92394:~/asl3_wx_announce $ sudo python3 -m asl3_wx_announce.main --report
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 260, in <module>
main()
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 246, in main
setup_logging(config) # Call the placeholder setup_logging
^^^^^^^^^^^^^
NameError: name 'setup_logging' is not defined
admin@node92394:~/asl3_wx_announce $

@ -0,0 +1,23 @@
INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata type: <class 'env_canada.ec_weather.MetaData'>
INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata content: MetaData(attribution='Data provided by Environment Canada', timestamp=datetime.datetime(2026, 1, 26, 20, 1, 29, tzinfo=tzutc()), station='Gatineau Airport', location='Gatineau', cache_returned_on_update=0, last_update_error='')
INFO:asl3_wx:Resolved Location: Unknown, Canada (CA)
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 109, in <module>
main()
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 102, in main
do_full_report(config)
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 31, in do_full_report
conditions = provider.get_conditions(lat, lon)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/admin/asl3_wx_announce/asl3_wx_announce/provider/ec.py", line 46, in get_conditions
return CurrentConditions(
^^^^^^^^^^^^^^^^^^
File "/home/admin/.local/lib/python3.11/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for CurrentConditions
description
Input should be a valid string [type=string_type, input_value={'label': 'Condition', 'value': 'Light Snow'}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.12/v/string_type

@ -0,0 +1,8 @@
requests
astral
pydantic
pyyaml
env_canada
reverse_geocoder
numpy
pytz

@ -0,0 +1,45 @@
import subprocess
import os
import sys
TEXT = "This is a test of the text to speech conversion. If you hear this, the text file conversion is working."
WAV_TMP = "/tmp/tts_test_raw.wav"
WAV_FINAL_PATH = "/var/lib/asterisk/sounds/tts_test_final.wav"
WAV_FINAL_NAME = "tts_test_final"
NODE = "62394"
def run(cmd):
print(f"Executing: {cmd}")
try:
subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as e:
print(f"Error running command: {e}")
sys.exit(1)
def main():
print("--- Starting TTS Isolation Test ---")
# 1. Generate WAV from Text
print(f"1. Generating audio for text: '{TEXT}'")
run(f"pico2wave -w {WAV_TMP} \"{TEXT}\"")
# 2. Convert to Asterisk format (8kHz, 16bit, Mono)
print("2. Converting format with Sox...")
# Clean up old file if exists
if os.path.exists(WAV_FINAL_PATH):
os.remove(WAV_FINAL_PATH)
run(f"sox {WAV_TMP} -r 8000 -c 1 -b 16 -e signed-integer {WAV_FINAL_PATH}")
# 3. Fix permissions
print("3. Setting permissions...")
os.chmod(WAV_FINAL_PATH, 0o644)
# 4. Playback
print(f"4. Playing on node {NODE}...")
run(f"sudo /usr/sbin/asterisk -rx 'rpt playback {NODE} {WAV_FINAL_NAME}'")
print("--- Test Complete ---")
if __name__ == "__main__":
main()
Loading…
Cancel
Save

Powered by TurnKey Linux.