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