commit
d6ba3e62b8
@ -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…
Reference in new issue