From d6ba3e62b8478223479d210a39419c9c83dc5f7a Mon Sep 17 00:00:00 2001 From: swanie98635 Date: Wed, 28 Jan 2026 18:46:22 -0800 Subject: [PATCH] Initial commit of ASL3 Weather Announcer --- .gitignore | 20 ++ LICENSE | 21 ++ README.md | 93 ++++++ asl3-wx.service | 14 + asl3_wx_announce/audio.py | 179 ++++++++++++ asl3_wx_announce/location.py | 66 +++++ asl3_wx_announce/main.py | 351 +++++++++++++++++++++++ asl3_wx_announce/models.py | 50 ++++ asl3_wx_announce/narrator.py | 343 ++++++++++++++++++++++ asl3_wx_announce/provider/alert_ready.py | 190 ++++++++++++ asl3_wx_announce/provider/astro.py | 33 +++ asl3_wx_announce/provider/base.py | 42 +++ asl3_wx_announce/provider/ec.py | 151 ++++++++++ asl3_wx_announce/provider/factory.py | 45 +++ asl3_wx_announce/provider/nws.py | 157 ++++++++++ config.example.yaml | 58 ++++ config.yaml.example | 26 ++ config_fixed.yaml | 12 + debug_ec.py | 18 ++ debugv2.log | 30 ++ deploy.ps1 | 45 +++ diagnose_audio.py | 91 ++++++ err pastes/err 3 | 22 ++ err pastes/err1 | 85 ++++++ err pastes/err2 | 11 + pydantic.log | 23 ++ requirements.txt | 8 + test_tts.py | 45 +++ 28 files changed, 2229 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 asl3-wx.service create mode 100644 asl3_wx_announce/audio.py create mode 100644 asl3_wx_announce/location.py create mode 100644 asl3_wx_announce/main.py create mode 100644 asl3_wx_announce/models.py create mode 100644 asl3_wx_announce/narrator.py create mode 100644 asl3_wx_announce/provider/alert_ready.py create mode 100644 asl3_wx_announce/provider/astro.py create mode 100644 asl3_wx_announce/provider/base.py create mode 100644 asl3_wx_announce/provider/ec.py create mode 100644 asl3_wx_announce/provider/factory.py create mode 100644 asl3_wx_announce/provider/nws.py create mode 100644 config.example.yaml create mode 100644 config.yaml.example create mode 100644 config_fixed.yaml create mode 100644 debug_ec.py create mode 100644 debugv2.log create mode 100644 deploy.ps1 create mode 100644 diagnose_audio.py create mode 100644 err pastes/err 3 create mode 100644 err pastes/err1 create mode 100644 err pastes/err2 create mode 100644 pydantic.log create mode 100644 requirements.txt create mode 100644 test_tts.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ab03c8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47736a7 --- /dev/null +++ b/README.md @@ -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 diff --git a/asl3-wx.service b/asl3-wx.service new file mode 100644 index 0000000..d9e6a32 --- /dev/null +++ b/asl3-wx.service @@ -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 diff --git a/asl3_wx_announce/audio.py b/asl3_wx_announce/audio.py new file mode 100644 index 0000000..b62b4f9 --- /dev/null +++ b/asl3_wx_announce/audio.py @@ -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 diff --git a/asl3_wx_announce/location.py b/asl3_wx_announce/location.py new file mode 100644 index 0000000..02c4c95 --- /dev/null +++ b/asl3_wx_announce/location.py @@ -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") diff --git a/asl3_wx_announce/main.py b/asl3_wx_announce/main.py new file mode 100644 index 0000000..15444fc --- /dev/null +++ b/asl3_wx_announce/main.py @@ -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() diff --git a/asl3_wx_announce/models.py b/asl3_wx_announce/models.py new file mode 100644 index 0000000..135d6fc --- /dev/null +++ b/asl3_wx_announce/models.py @@ -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 diff --git a/asl3_wx_announce/narrator.py b/asl3_wx_announce/narrator.py new file mode 100644 index 0000000..b30e2ac --- /dev/null +++ b/asl3_wx_announce/narrator.py @@ -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." diff --git a/asl3_wx_announce/provider/alert_ready.py b/asl3_wx_announce/provider/alert_ready.py new file mode 100644 index 0000000..d60741d --- /dev/null +++ b/asl3_wx_announce/provider/alert_ready.py @@ -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 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 diff --git a/asl3_wx_announce/provider/astro.py b/asl3_wx_announce/provider/astro.py new file mode 100644 index 0000000..ad6eaf1 --- /dev/null +++ b/asl3_wx_announce/provider/astro.py @@ -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" diff --git a/asl3_wx_announce/provider/base.py b/asl3_wx_announce/provider/base.py new file mode 100644 index 0000000..8253bbc --- /dev/null +++ b/asl3_wx_announce/provider/base.py @@ -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 diff --git a/asl3_wx_announce/provider/ec.py b/asl3_wx_announce/provider/ec.py new file mode 100644 index 0000000..a2221fe --- /dev/null +++ b/asl3_wx_announce/provider/ec.py @@ -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 + diff --git a/asl3_wx_announce/provider/factory.py b/asl3_wx_announce/provider/factory.py new file mode 100644 index 0000000..93df593 --- /dev/null +++ b/asl3_wx_announce/provider/factory.py @@ -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.") diff --git a/asl3_wx_announce/provider/nws.py b/asl3_wx_announce/provider/nws.py new file mode 100644 index 0000000..350075e --- /dev/null +++ b/asl3_wx_announce/provider/nws.py @@ -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 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e8c0536 --- /dev/null +++ b/config.example.yaml @@ -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) diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..0d0e6a5 --- /dev/null +++ b/config.yaml.example @@ -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" diff --git a/config_fixed.yaml b/config_fixed.yaml new file mode 100644 index 0000000..4302c0b --- /dev/null +++ b/config_fixed.yaml @@ -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 diff --git a/debug_ec.py b/debug_ec.py new file mode 100644 index 0000000..5b85d2b --- /dev/null +++ b/debug_ec.py @@ -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}") diff --git a/debugv2.log b/debugv2.log new file mode 100644 index 0000000..f3b4f08 --- /dev/null +++ b/debugv2.log @@ -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: +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 "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 109, in + 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. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..fad1cbf --- /dev/null +++ b/deploy.ps1 @@ -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" diff --git a/diagnose_audio.py b/diagnose_audio.py new file mode 100644 index 0000000..a961ce6 --- /dev/null +++ b/diagnose_audio.py @@ -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() diff --git a/err pastes/err 3 b/err pastes/err 3 new file mode 100644 index 0000000..f2ee314 --- /dev/null +++ b/err pastes/err 3 @@ -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 + diff --git a/err pastes/err1 b/err pastes/err1 new file mode 100644 index 0000000..db8ad6d --- /dev/null +++ b/err pastes/err1 @@ -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(': Failed to establish a new connection: [Errno -2] Name or service not known')) +PS C:\Users\eric\.gemini\antigravity\scratch\Alert ASL> \ No newline at end of file diff --git a/err pastes/err2 b/err pastes/err2 new file mode 100644 index 0000000..982f6bd --- /dev/null +++ b/err pastes/err2 @@ -0,0 +1,11 @@ +admin@node92394:~/asl3_wx_announce $ sudo python3 -m asl3_wx_announce.main --report +Traceback (most recent call last): + File "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 260, in + 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 $ \ No newline at end of file diff --git a/pydantic.log b/pydantic.log new file mode 100644 index 0000000..1eee700 --- /dev/null +++ b/pydantic.log @@ -0,0 +1,23 @@ +INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata type: +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 "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 109, in + 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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55f47eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +requests +astral +pydantic +pyyaml +env_canada +reverse_geocoder +numpy +pytz diff --git a/test_tts.py b/test_tts.py new file mode 100644 index 0000000..c0b09b9 --- /dev/null +++ b/test_tts.py @@ -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()