diff --git a/README.md b/README.md index 42acdd4..8b79f90 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ASL3 Weather Announcer +/# ASL3 Weather Announcer **ASL3 Weather Announcer** is a flexible, multi-country weather alert and reporting system designed for AllStarLink 3 (Asterisk) nodes. @@ -9,14 +9,17 @@ It provides **automated verbal announcements** for: * **Startup Status**: System readiness and monitoring interval announcements. ## Features - + * **Multi-Provider Support**: * 🇺🇸 **USA**: Uses National Weather Service (NWS) API. * 🇨🇦 **Canada**: Uses Environment Canada & NAAD Alert Ready (CAP). * **Dynamic Polling**: * Polls every 10 minutes (configurable) normally. - * **Automatically speeds up to 1 minute** during active Watches/Warnings. + * **Automatically speeds up** (configurable, e.g., 1 min) during active Watches/Warnings. * Verbal announcements when polling interval changes. +* **Hourly Reports**: + * Configurable content: Conditions, Forecast, Astro (Sun/Moon), **Solar Flux Index**, System Status, **Exact Time**. + * **Time Accuracy Check**: Checks system clock against NIST/NRC and warns if drift > 60s. * **Smart Location**: * **Geospatial Filtering**: Uses CAP polygons to determine if *your* specific location is in the alert area. * **Static**: Configurable fixed lat/lon. @@ -24,7 +27,7 @@ It provides **automated verbal announcements** for: * Generates prompts using `pico2wave` (or configurable TTS). * Plays directly to local or remote ASL3 nodes via `rpt playback`. * **Reliability**: - * Systemd service integration. + * Systemd service integration (runs in dedicated `venv`). * Robust "Wait for Asterisk" boot logic. ## Installation @@ -33,8 +36,9 @@ It provides **automated verbal announcements** for: On your ASL3 server (Debian/Raspbian): ```bash sudo apt update -sudo apt install python3-pip libttspico-utils gpsd sox +sudo apt install python3-pip libttspico-utils gpsd sox chrony ``` +*(Note: `chrony` is recommended for time accuracy checks)* ### Deploying Code The recommended install location is `/opt/asl3_wx_announce`. @@ -42,13 +46,14 @@ The recommended install location is `/opt/asl3_wx_announce`. **Using the Deployment Script (Windows/PowerShell):** 1. Update `config.yaml` with your settings. 2. Run `.\deploy.ps1`. - * This script bundles the code, uploads it via SSH, installs dependencies, and registers/restarts the systemd service. + * This script bundles the code, uploads it via SSH, creates a Python virtual environment, installs dependencies, and registers/restarts the systemd service. **Manual Installation (Linux):** 1. Copy files to `/opt/asl3_wx_announce`. -2. Install requirements: `pip3 install -r requirements.txt`. -3. Copy `asl3-wx.service` to `/etc/systemd/system/`. -4. Enable and start: `sudo systemctl enable --now asl3-wx`. +2. Create venv: `python3 -m venv venv` +3. Install requirements: `venv/bin/pip install -r requirements.txt`. +4. Copy `asl3-wx.service` to `/etc/systemd/system/`. +5. Enable and start: `sudo systemctl enable --now asl3-wx`. ## Configuration @@ -57,7 +62,7 @@ Copy the example config: cp config.example.yaml config.yaml ``` -Edit `config.yaml`: +Edit `config.yaml` (See `config.example.yaml` for full options): ```yaml location: source: fixed @@ -66,13 +71,23 @@ location: station: callsign: "N7XOB" - report_style: "quick" # 'quick' (2 days) or 'verbose' (7 days) + hourly_report: + enabled: true + minute: 0 + content: + time: true + time_error: true # Check clock accuracy + conditions: true + forecast: true + forecast_verbose: false + astro: true + solar_flux: true # NOAA SWPC Data + status: true alerts: min_severity: "Watch" check_interval_minutes: 10 - # Alert Ready (Canada) - enable_alert_ready: true + active_check_interval_minutes: 1 # Faster polling during events ``` ## Usage diff --git a/asl3-wx.service b/asl3-wx.service index d9e6a32..438c568 100644 --- a/asl3-wx.service +++ b/asl3-wx.service @@ -6,7 +6,7 @@ After=network.target sound.target asterisk.service Type=simple User=root WorkingDirectory=/opt/asl3_wx_announce -ExecStart=/usr/bin/python3 -m asl3_wx_announce.main --monitor +ExecStart=/opt/asl3_wx_announce/venv/bin/python -m asl3_wx_announce.main --monitor Restart=always RestartSec=60 diff --git a/asl3_wx_announce/main.py b/asl3_wx_announce/main.py index 15444fc..42d7a6f 100644 --- a/asl3_wx_announce/main.py +++ b/asl3_wx_announce/main.py @@ -3,6 +3,7 @@ import yaml import time import logging import subprocess +import sys from datetime import datetime from .models import AlertSeverity from .location import LocationService @@ -53,11 +54,10 @@ def setup_logging(config): logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) -def do_full_report(config): +def do_full_report(config, report_config=None): 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) @@ -101,7 +101,7 @@ def do_full_report(config): # Narrate narrator = Narrator(config) - text = narrator.build_full_report(loc_info, conditions, forecast, alerts, sun_info) + text = narrator.build_full_report(loc_info, conditions, forecast, alerts, sun_info, report_config=report_config) logger.info(f"Report Text: {text}") # Audio @@ -152,8 +152,23 @@ def monitor_loop(config): info = provider.get_location_info(lat, lon) city_name = info.city if info.city else "Unknown Location" interval_mins = int(normal_interval / 60) + active_mins = config.get('alerts', {}).get('active_check_interval_minutes', 1) + + # Determine Source Name + source_name = "National Weather Service" # Default fallback + prov_class = provider.__class__.__name__ + + if prov_class == 'ECProvider': + source_name = "Environment Canada" + if ar_provider: + source_name += " and National Alert Ready System" + elif prov_class == 'NWSProvider': + source_name = "National Weather Service" + + # Get Hourly Config + hourly_cfg = config.get('station', {}).get('hourly_report', {}) - startup_text = narrator.get_startup_message(city_name, interval_mins) + startup_text = narrator.get_startup_message(info, interval_mins, active_mins, nodes, source_name, hourly_config=hourly_cfg) logger.info(f"Startup Announcement: {startup_text}") # Generate and Play @@ -172,12 +187,26 @@ def monitor_loop(config): 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...") + # Check config struct + hr_config = config.get('station', {}).get('hourly_report', {}) + # Handle legacy boolean if present (though we updated config) + if isinstance(hr_config, bool): + hr_enabled = hr_config + hr_minute = 0 + hr_content = None # Use default + else: + hr_enabled = hr_config.get('enabled', False) + hr_minute = hr_config.get('minute', 0) + hr_content = hr_config.get('content') # dict or None + + if hr_enabled: + # Check if minute matches and we haven't run this hour yet + # Note: We track last_report_hour. If minute is 15, we run at 10:15. + # We need to ensure we don't run multiple times in the same hour. + if now_dt.minute == hr_minute and now_dt.hour != last_report_hour: + logger.info(f"Triggering Hourly Weather Report (Scheduled for XX:{hr_minute:02d})...") try: - do_full_report(config) + do_full_report(config, report_config=hr_content) last_report_hour = now_dt.hour except Exception as e: logger.error(f"Hourly Report Failed: {e}") @@ -220,14 +249,16 @@ def monitor_loop(config): if rank > max_severity_rank: max_severity_rank = rank - # If Watch (2) or Higher, set interval to 1 minute + # If Watch (2) or Higher, set interval to configured active interval new_interval = normal_interval active_threat = False if max_severity_rank >= 2: - new_interval = 60 + # Default to 1 minute if not set + active_mins = config.get('alerts', {}).get('active_check_interval_minutes', 1) + new_interval = active_mins * 60 active_threat = True - logger.info("Active Watch/Warning/Critical detected. Requesting 1-minute polling.") + logger.info(f"Active Watch/Warning/Critical detected. Requesting {active_mins}-minute polling.") # Check for Interval Change if new_interval != current_interval: @@ -334,13 +365,21 @@ def main(): args = parser.parse_args() config = load_config(args.config) - setup_logging(config) # Call the placeholder setup_logging - + # setup_logging(config) - removed double call, typically called once? + # Ah, setup_logging was called in my previous version. + if args.test_alert: do_test_alert(config) sys.exit(0) elif args.report: - do_full_report(config) + # Pass the hourly report content config if available so manual run matches scheduled run + hr_config = config.get('station', {}).get('hourly_report', {}) + if isinstance(hr_config, bool): + content = None + else: + content = hr_config.get('content') + + do_full_report(config, report_config=content) sys.exit(0) elif args.monitor: monitor_loop(config) diff --git a/asl3_wx_announce/narrator.py b/asl3_wx_announce/narrator.py index b30e2ac..c216414 100644 --- a/asl3_wx_announce/narrator.py +++ b/asl3_wx_announce/narrator.py @@ -1,5 +1,7 @@ from datetime import datetime from typing import List +import subprocess +import re from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert class Narrator: @@ -50,21 +52,15 @@ class Narrator: f"Conditions are {current.description}{wind}." ) - def announce_forecast(self, forecasts: List[WeatherForecast], loc: LocationInfo) -> str: + def announce_forecast(self, forecasts: List[WeatherForecast], loc: LocationInfo, verbose: bool = True) -> 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) + limit = 4 if not verbose else len(forecasts) for f in forecasts[:limit]: temp = "" @@ -98,8 +94,6 @@ class Narrator: 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}. " @@ -111,8 +105,6 @@ class Narrator: 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 @@ -124,9 +116,53 @@ class Narrator: return text - def build_full_report(self, loc: LocationInfo, current: CurrentConditions, forecast: List[WeatherForecast], alerts: List[WeatherAlert], sun_info: str = "") -> str: + def get_clock_offset(self) -> float: + """Returns the system clock offset in seconds, or None if unavailable.""" + try: + result = subprocess.run(['chronyc', 'tracking'], capture_output=True, text=True, timeout=2) + if result.returncode != 0: + return None + match = re.search(r"Last offset\s*:\s*([+-]?\d+\.\d+)\s*seconds", result.stdout) + if match: + return float(match.group(1)) + except Exception: + pass + return None + + def get_time_offset_message(self, agency_name: str) -> str: + """ + Returns string like "The system clock differs from [Agency] official time by 0.3 seconds." + """ + offset = self.get_clock_offset() + if offset is None: + return "" + + abs_offset = abs(offset) + # Format: "0.310" + offset_str = f"{abs_offset:.3f}".rstrip('0').rstrip('.') + + if abs_offset < 0.000001: return "" # Too small + + return f"The system clock differs from {agency_name} official time by {offset_str} seconds." + + + def build_full_report(self, loc: LocationInfo, current: CurrentConditions, forecast: List[WeatherForecast], alerts: List[WeatherAlert], sun_info: str = "", report_config: dict = None) -> str: parts = [] + # Default Config if None (Backward Compat) + if report_config is None: + report_config = { + 'time': True, + 'time_error': False, + 'conditions': True, + 'forecast': True, + 'forecast_verbose': False, + 'astro': True, + 'solar_flux': False, + 'status': False, + 'code_source': False + } + # Time string: "10 30 PM" import pytz tz_name = self.config.get('location', {}).get('timezone', 'UTC') @@ -137,8 +173,6 @@ class Narrator: 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:] @@ -158,9 +192,9 @@ class Narrator: } 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 + region_full = states_map.get(region_clean, loc.region) + + # Intro callsign logic callsign = self.config.get('station', {}).get('callsign') full_callsign = callsign or "" @@ -170,131 +204,101 @@ class Narrator: 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..." + # Simplified portable logic for brevity + if country == 'CA' and not upper_call.startswith('V'): + suffix = " Portable Canada" # Fallback/Simple 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}" + suffix = " Portable U S" 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.") + + # Time Section + if report_config.get('time', True): + # Timezone Formatting + tz_str = now.strftime("%Z") + tz_map = { + 'EST': 'Eastern Standard Time', 'EDT': 'Eastern Daylight Time', + 'CST': 'Central Standard Time', 'CDT': 'Central Daylight Time', + 'MST': 'Mountain Standard Time', 'MDT': 'Mountain Daylight Time', + 'PST': 'Pacific Standard Time', 'PDT': 'Pacific Daylight Time', + 'AST': 'Atlantic Standard Time', 'ADT': 'Atlantic Daylight Time', + 'NST': 'Newfoundland Standard Time', 'NDT': 'Newfoundland Daylight Time', + 'AKST': 'Alaska Standard Time', 'AKDT': 'Alaska Daylight Time', + 'HST': 'Hawaii Standard Time', 'HDT': 'Hawaii Daylight Time' + } + full_tz = tz_map.get(tz_str, tz_str) + # Attribution + agency = "National Atomic" + if loc.country_code == "US": + agency = "National Institute of Standards and Technology" + elif loc.country_code == "CA": + agency = "National Research Council Canada" + + parts.append(f"The time is {now_str} {full_tz}. Time provided by the {agency}.") + + # Time Error Section (New Separate) + if report_config.get('time_error', False): + # Determine Agency Short Name for Error Message + agency_short = "National Atomic" + if loc.country_code == "US": + agency_short = "National Institute of Standards and Technology" + elif loc.country_code == "CA": + agency_short = "National Research Council" + + msg = self.get_time_offset_message(agency_short) + if msg: parts.append(msg + " Break. [PAUSE]") - - # 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)) + # Conditions + if report_config.get('conditions', True): + parts.append(self.announce_conditions(loc, current) + " Break. [PAUSE]") + + # Forecast + if report_config.get('forecast', True): + is_verbose = report_config.get('forecast_verbose', False) + parts.append(self.announce_forecast(forecast, loc, verbose=is_verbose)) - if sun_info: + # Astro (Sun/Moon) + if report_config.get('astro', True) and 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. + # Solar Flux + if report_config.get('solar_flux', False): + try: + from .provider.astro import AstroProvider + ap = AstroProvider() + sfi = ap.get_solar_flux() + if sfi: parts.append(sfi + " Break. [PAUSE]") + except Exception: pass + + # Status + if report_config.get('status', False): + interval = self.config.get('alerts', {}).get('check_interval_minutes', 10) + sev = self.config.get('alerts', {}).get('min_severity', 'Watch') + tone_sev = self.config.get('alerts', {}).get('alert_tone', {}).get('min_severity', 'Warning') + parts.append(f"System status: Monitoring for {sev} level events and higher. Tone alerts active for {tone_sev} events and higher. Monitoring interval is {interval} minutes. Break. [PAUSE]") + + # Code Source + if report_config.get('code_source', False): + parts.append("A S L 3 underscore w x underscore announce is available on github and developed by N 7 X O B as non-commercial code provided for the benefit of the amateur radio community.") + + # Agency Credits + if loc.country_code == 'US': + parts.append("Data provided by the National Weather Service and the National Institute of Standards and Technology.") + elif loc.country_code == 'CA': + parts.append("Data provided by Environment Canada, National Research Council Canada, and the National Alert Aggregation and Dissemination System.") + + parts.append("Break. [PAUSE]") + + # Outro parts.append(f"This concludes the weather report for {loc.city}. {full_callsign} Clear.") return " ".join(parts) @@ -326,15 +330,108 @@ class Narrator: "This test is concluded." ) - def get_startup_message(self, city: str, interval: int) -> str: + + def get_startup_message(self, loc: LocationInfo, interval: int, active_interval: int, nodes: List[str], source_name: str, hourly_config: dict = None) -> 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. " + city = loc.city if loc.city else "Unknown Location" + + node_str = "" + if nodes: + # Format nodes to be read as digits: "1234" -> "1 2 3 4" + formatted_nodes = [" ".join(list(str(n))) for n in nodes] + + if len(formatted_nodes) == 1: + node_str = f"Active on node {formatted_nodes[0]}. " + else: + joined = " and ".join(formatted_nodes) + node_str = f"Active on nodes {joined}. " + + # Time Calc + 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") + if now_str.startswith("0"): + now_str = now_str[1:] + + # Timezone Formatting + tz_str = now.strftime("%Z") + tz_map = { + 'EST': 'Eastern Standard Time', 'EDT': 'Eastern Daylight Time', + 'CST': 'Central Standard Time', 'CDT': 'Central Daylight Time', + 'MST': 'Mountain Standard Time', 'MDT': 'Mountain Daylight Time', + 'PST': 'Pacific Standard Time', 'PDT': 'Pacific Daylight Time', + 'AST': 'Atlantic Standard Time', 'ADT': 'Atlantic Daylight Time', + 'NST': 'Newfoundland Standard Time', 'NDT': 'Newfoundland Daylight Time', + 'AKST': 'Alaska Standard Time', 'AKDT': 'Alaska Daylight Time', + 'HST': 'Hawaii Standard Time', 'HDT': 'Hawaii Daylight Time' + } + full_tz = tz_map.get(tz_str, tz_str) + + # Offset Check + agency_short = "National Atomic" + if loc.country_code == "US": + agency_short = "National Institute of Standards and Technology" + elif loc.country_code == "CA": + agency_short = "National Research Council" + + # We manually construct message here to add the warning if needed + offset = self.get_clock_offset() + offset_msg = "" + + if offset is not None: + abs_offset = abs(offset) + if abs_offset >= 0.000001: + offset_str = f"{abs_offset:.3f}".rstrip('0').rstrip('.') + offset_msg = f"The system clock differs from {agency_short} official time by {offset_str} seconds." + + # Critical Warning > 60s + if abs_offset > 60: + offset_msg += " Time Error warrants correction to ensure timely notifications." + + time_msg = f"The time is {now_str} {full_tz}. {offset_msg}" + + msg = ( + f"{callsign} Automatic Monitoring Online. " + f"Data provided by {source_name}. " + f"{node_str}" f"Current location {city}. " - f"Checking interval is {interval} minutes." + f"{time_msg} " + f"Active Alert Check Interval {interval} minutes. " + f"Dynamic Alert Check Interval {active_interval} minutes. " ) + if hourly_config and hourly_config.get('enabled', False): + minute = hourly_config.get('minute', 0) + msg += f"Hourly report is on. Hourly notifications will occur at {minute} minutes after the hour. " + + content = hourly_config.get('content', {}) + enabled_items = [] + + order = ['time', 'time_error', 'conditions', 'forecast', 'forecast_verbose', 'astro', 'solar_flux', 'status', 'code_source'] + + for key in order: + if content.get(key, False): + # Special handling for nicer speech + spoken = key.replace('_', ' ') + if key == 'astro': spoken = 'astronomy' + enabled_items.append(spoken) + + if enabled_items: + if len(enabled_items) == 1: + msg += f"Content includes {enabled_items[0]}." + else: + # Join with commas, and add "and" before last + joined_content = ", ".join(enabled_items[:-1]) + " and " + enabled_items[-1] + msg += f"Content includes {joined_content}." + + return msg + def get_interval_change_message(self, interval_mins: int, active_alerts: bool) -> str: callsign = self.config.get('station', {}).get('callsign', 'Station') if active_alerts: diff --git a/asl3_wx_announce/provider/astro.py b/asl3_wx_announce/provider/astro.py index ad6eaf1..bc4a236 100644 --- a/asl3_wx_announce/provider/astro.py +++ b/asl3_wx_announce/provider/astro.py @@ -6,6 +6,7 @@ from ..models import LocationInfo class AstroProvider: def get_astro_info(self, loc: LocationInfo) -> str: + parts = [] try: # Astral uses its own LocationInfo City = AstralLoc(loc.city, loc.region, loc.timezone, loc.latitude, loc.longitude) @@ -13,13 +14,68 @@ class AstroProvider: sunrise = s['sunrise'].strftime("%I %M %p") sunset = s['sunset'].strftime("%I %M %p") + parts.append(f"Sunrise is at {sunrise}. Sunset is at {sunset}.") # Moon phase: 0..27 ph = phase(datetime.now()) moon_desc = self._describe_phase(ph) + parts.append(f"The moon is {moon_desc}.") + + # Solar Flux + sfi_msg = self.get_solar_flux() + if sfi_msg: + parts.append(sfi_msg) + + return " ".join(parts) + + except Exception as e: + # logger.error(f"Astro error: {e}") + return "" + + def get_solar_flux(self) -> str: + """ + Fetches the latest Solar Flux Index (SFI) from NOAA SWPC. + "SFI taken (date time) from the Penticton Radio Observatory in Penticton, British Columbia, + as reported by the National Weather Service Space Weather Prediction Center." + """ + import requests + from dateutil.parser import parse as parse_date + import pytz + + url = "https://services.swpc.noaa.gov/products/summary/10cm-flux.json" + try: + resp = requests.get(url, timeout=5) + resp.raise_for_status() + data = resp.json() + # Expected format: {"Flux": "165", "Time": "2025-01-29 18:00"} OR similar + + flux = data.get('Flux') + timestamp_str = data.get('TimeStamp') # Verify key case + + if not flux or not timestamp_str: + return "" + + # Parse time + # Timestamp is usually UTC. + dt = parse_date(timestamp_str) + # Format: Month Day, Hour Minute UTC? + # Or just "Date Time" as user asked? User said "SFI taken (date time)..." + # Let's format nicely: "January 29 at 10 00 UTC" + + # Ensure it is treated as UTC if naive + if dt.tzinfo is None: + dt = dt.replace(tzinfo=pytz.UTC) + + dt_fmt = dt.strftime("%B %d at %H %M UTC") + + return ( + f"Solar Flux Index is {flux}. " + f"S F I taken {dt_fmt} from the Penticton Radio Observatory in Penticton, British Columbia, " + f"as reported by the National Weather Service Space Weather Prediction Center." + ) - return f"Sunrise is at {sunrise}. Sunset is at {sunset}. The moon is {moon_desc}." except Exception as e: + # Fail silently for SFI return "" def _describe_phase(self, day: float) -> str: diff --git a/config.example.yaml b/config.example.yaml index e8c0536..4c839e5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -9,9 +9,19 @@ location: 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 + hourly_report: + enabled: true + minute: 0 # Run at this minute past the hour (0-59) + content: + time: true # Time check + time_error: true # System clock offset check + conditions: true # Current Conditions + forecast: true # Forecast + forecast_verbose: false # true = Full forecast, false = Quick forecast + astro: true # Sunrise/Sunset/Moon + solar_flux: true # Solar Flux Index (SFI) + status: true # System Status + code_source: true # Credits voice: # Example utilizing pico2wave (sudo apt install libttspico-utils) @@ -23,6 +33,8 @@ voice: alerts: check_interval_minutes: 10 + # Active Check Interval (when Severe Alerts are present) + active_check_interval_minutes: 1 # Minimum Severity to Report. Options: # - "Advisory", "Watch", "Warning", "Critical" min_severity: "Watch" diff --git a/deploy.ps1 b/deploy.ps1 index fad1cbf..39fd981 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -29,17 +29,66 @@ if ($LASTEXITCODE -ne 0) { # 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" +# 1. Create directory +# 2. Extract +# 3. Create venv if not exists +# 4. Install requirements in venv +# 5. Setup service +$REMOTE_CMD = "sudo mkdir -p ${REMOTE_PATH} && sudo tar -xvf /tmp/${TAR_FILE} -C ${REMOTE_PATH} && rm /tmp/${TAR_FILE} && sudo python3 -m venv ${REMOTE_PATH}/venv && sudo ${REMOTE_PATH}/venv/bin/pip install -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" +Write-Host "Deployment Complete! The 'asl3-wx' service is running in the background." -ForegroundColor Green +Write-Host "To check status/logs:" +Write-Host " ssh ${REMOTE_USER}@${REMOTE_HOST} 'sudo systemctl status asl3-wx'" +Write-Host " ssh ${REMOTE_USER}@${REMOTE_HOST} 'sudo journalctl -u asl3-wx -f'" +Write-Host "To stop:" +Write-Host " ssh ${REMOTE_USER}@${REMOTE_HOST} 'sudo systemctl stop asl3-wx'" + +# --- Display Config Summary --- +Write-Host "" +Write-Host "--- Configuration Summary ---" -ForegroundColor Yellow + +$configContent = Get-Content config.yaml -Raw + +# Helper function for regex extraction +function Get-ConfigValue { + param ($pattern, $default = "Unknown") + if ($configContent -match $pattern) { return $Matches[1].Trim('"').Trim("'") } + return $default +} + +$callsign = Get-ConfigValue "callsign:\s*(.*)" +$check_int = Get-ConfigValue "check_interval_minutes:\s*(\d+)" +$active_int = Get-ConfigValue "active_check_interval_minutes:\s*(\d+)" +$timezone = Get-ConfigValue "timezone:\s*(.*)" +$hourly_enabled = Get-ConfigValue "hourly_report:\s*\n\s*enabled:\s*(true|false)" "false" +$hourly_minute = Get-ConfigValue "minute:\s*(\d+)" + +Write-Host "Callsign: $callsign" +Write-Host "Timezone: $timezone" +Write-Host "Check Interval: $check_int minutes (Normal)" +Write-Host "Active Interval: $active_int minutes (During Alerts)" + +if ($hourly_enabled -eq "true") { + Write-Host "Hourly Report: Enabled (at minute $hourly_minute)" + + # Parse Content flags + $content_keys = "time", "time_error", "conditions", "forecast", "forecast_verbose", "astro", "solar_flux", "status", "code_source" + $enabled_content = @() + foreach ($key in $content_keys) { + if ($configContent -match "${key}:\s*true") { + $enabled_content += $key + } + } + $content_str = $enabled_content -join ", " + Write-Host "Hourly Content: $content_str" +} +else { + Write-Host "Hourly Report: Disabled" +} +Write-Host "-----------------------------" +Write-Host "" diff --git a/err pastes/err1 b/err pastes/err1 index db8ad6d..5c6b3e7 100644 --- a/err pastes/err1 +++ b/err pastes/err1 @@ -1,85 +1,5 @@ -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 +SFI below ~70 (solar minimum conditions): These low values indicate very weak solar activity. The MUF might only be in the low HF range (10 MHz or below) . You can expect poor high-band conditions – 20 m (14 MHz) may be marginal, and 15 m (21 MHz) or 10 m (28 MHz) will likely be closed for long-distance DX. Long-distance HF communication will be mostly confined to lower bands like 40 m and 80 m (which rely less on F2 ionization) . In summary, SFI < 70 is considered “poor” for upper HF and is typical of solar minima . +SFI ~70–90: Still on the low side, but slightly improved. 20 m daytime openings become more reliable as SFI climbs through the 70s and 80s. This range might be described as “poor to fair” HF conditions. You may get some short or sporadic openings on 15 m around peak sun hours, but 10 m will mostly remain quiet. Many HF operators consider SFI in the 80s to be low-average conditions . +SFI ~90–120: This is a moderate level of solar flux, often occurring in the rising or falling phases of the solar cycle. Here we reach “average” HF conditions, turning toward good. Frequencies up to ~21–24 MHz (15 m to 12 m bands) can open consistently in daytime . DXers will notice 20 m is solid all day, 17 m/15 m open most days, and even 10 m (28 MHz) may open on strong days when SFI is above ~100 . An SFI in the 100+ ballpark generally indicates the Sun is active enough to make high-band HF interesting again. +SFI ~120–150: These values signify significant solar activity. HF conditions in this range are “good” up through 10 m on many days . Expect regular openings on 10 m, especially at lower latitudes or during peak sun hours, and even 6 m (50 MHz) sporadic-E or F2 layer openings can occur when SFI nears the 150 mark in combination with seasonal factors . In this range, all the HF bands from 80 m through 10 m can offer long-distance propagation at various times of day or night. Operators with modest antennas will start to enjoy worldwide DX on high bands when SFI consistently sits in the 130–150 range . +SFI > 150: When the solar flux index rises above about 150 and stays there, we reach ideal HF conditions for the higher bands . This is typically seen near the peak of the 11-year solar cycle. An SFI of 150–200 means “excellent” propagation on all bands up through 10 m, with 6 m openings also likely . Worldwide communications on 10 m become routine; even daylight 40 m and 30 m propagation improves thanks to increased ionization at all altitudes. Experienced HF DXers often say that SFI sustained over 150 is the magic ingredient for consistent global DX on 10 m and 12 m. Moreover, values above 200 (when the Sun is very active) indicate maximum peak conditions – the F2 layer is highly charged, and one can expect the absolute best HF propagation, with daily 10 m openings and daytime MUFs potentially exceeding 50 MHz . During such times (usually the crest of the solar cycle), even small portable radios or compromise antennas can make surprising long-range contacts on HF bands . Keep in mind, values in the 200–300 range are usually seen only for relatively short periods during the solar maximum, but they truly light up the ionosphere. \ No newline at end of file diff --git a/err pastes/err2 b/err pastes/err2 index 982f6bd..f2006d3 100644 --- a/err pastes/err2 +++ b/err pastes/err2 @@ -1,11 +1,65 @@ -admin@node92394:~/asl3_wx_announce $ sudo python3 -m asl3_wx_announce.main --report +admin@node92394:/opt/asl3_wx_announce $ sudo python3 -m asl3_wx_announce.main --report +Loading formatted geocoded file... +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, 29, 17, 5, 6, tzinfo=tzutc()), station="Quebec Lesage Int'l Airport", location='Québec', cache_returned_on_update=0, last_update_error='') +INFO:asl3_wx:Using Config Timezone: America/Toronto +INFO:asl3_wx:Resolved Location: Quebec, Quebec (CA) +INFO:asl3_wx:Report Text: CQ CQ CQ. This is N7XOB Portable V E 2 with the updated weather report. The time is 12 05 PM. This is the automated weather report for Quebec, Quebec. Break. [PAUSE] Current conditions for Quebec, Quebec. The temperature is -15 degrees celsius. Conditions are Partly Cloudy, with winds at 22 kilometers per hour. Break. [PAUSE] Here is the forecast. Thursday: Increasing cloudiness this afternoon. Wind southwest 20 km/h. High minus 13. Wind chill minus 24 this afternoon. UV index 1 or low.. High -13. Break. [PAUSE] Thursday night: Periods of light snow. Wind west 20 km/h gusting to 40. Low minus 17. Wind chill near minus 27.. High -17. Break. [PAUSE] Friday: Periods of light snow ending in the morning then mainly cloudy with 30 percent chance of flurries. Wind west 20 km/h gusting to 40. High minus 13. Wind chill minus 27 in the morning and minus 19 in the afternoon. UV index 1 or low.. High -13. Break. [PAUSE] Friday night: Clear. Low minus 19.. High -19. Break. [PAUSE] This concludes the weather report for Quebec. N7XOB Portable V E 2 Clear. +INFO:asl3_wx_announce.audio:Generating segment 0: pico2wave -w /tmp/asl3_wx/raw_report_0.wav "CQ CQ CQ. This is N7XOB Portable V E 2 with the updated weather report. The time is 12 05 PM. This is the automated weather report for Quebec, Quebec. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_0.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_0.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_0.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_0.gsm +INFO:asl3_wx_announce.audio:Generating segment 1: pico2wave -w /tmp/asl3_wx/raw_report_1.wav "Current conditions for Quebec, Quebec. The temperature is -15 degrees celsius. Conditions are Partly Cloudy, with winds at 22 kilometers per hour. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_1.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_1.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_1.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_1.gsm +INFO:asl3_wx_announce.audio:Generating segment 2: pico2wave -w /tmp/asl3_wx/raw_report_2.wav "Here is the forecast. Thursday: Increasing cloudiness this afternoon. Wind southwest 20 km/h. High minus 13. Wind chill minus 24 this afternoon. UV index 1 or low.. High -13. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_2.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_2.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_2.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_2.gsm +INFO:asl3_wx_announce.audio:Generating segment 3: pico2wave -w /tmp/asl3_wx/raw_report_3.wav "Thursday night: Periods of light snow. Wind west 20 km/h gusting to 40. Low minus 17. Wind chill near minus 27.. High -17. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_3.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_3.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_3.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_3.gsm +INFO:asl3_wx_announce.audio:Generating segment 4: pico2wave -w /tmp/asl3_wx/raw_report_4.wav "Friday: Periods of light snow ending in the morning then mainly cloudy with 30 percent chance of flurries. Wind west 20 km/h gusting to 40. High minus 13. Wind chill minus 27 in the morning and minus 19 in the afternoon. UV index 1 or low.. High -13. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_4.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_4.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_4.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_4.gsm +INFO:asl3_wx_announce.audio:Generating segment 5: pico2wave -w /tmp/asl3_wx/raw_report_5.wav "Friday night: Clear. Low minus 19.. High -19. Break." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_5.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_5.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_5.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_5.gsm +INFO:asl3_wx_announce.audio:Generating segment 6: pico2wave -w /tmp/asl3_wx/raw_report_6.wav "This concludes the weather report for Quebec. N7XOB Portable V E 2 Clear." +INFO:asl3_wx_announce.audio:Converting audio: sox /tmp/asl3_wx/raw_report_6.wav -r 8000 -c 1 -t gsm /tmp/asl3_wx/asl3_wx_report_6.gsm +INFO:asl3_wx_announce.audio:Moving to sounds dir: sudo mv /tmp/asl3_wx/asl3_wx_report_6.gsm /usr/share/asterisk/sounds/en/asl3_wx_report_6.gsm +INFO:asl3_wx_announce.audio:Segment 0 duration: 14.84s +INFO:asl3_wx_announce.audio:Playing segment 0 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_0" +INFO:asl3_wx_announce.audio:Playing segment 0 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_0" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 1 duration: 12.992s +INFO:asl3_wx_announce.audio:Playing segment 1 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_1" +INFO:asl3_wx_announce.audio:Playing segment 1 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_1" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 2 duration: 19.196s +INFO:asl3_wx_announce.audio:Playing segment 2 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_2" +INFO:asl3_wx_announce.audio:Playing segment 2 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_2" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 3 duration: 14.732s +INFO:asl3_wx_announce.audio:Playing segment 3 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_3" +INFO:asl3_wx_announce.audio:Playing segment 3 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_3" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 4 duration: 22.54s +INFO:asl3_wx_announce.audio:Playing segment 4 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_4" +INFO:asl3_wx_announce.audio:Playing segment 4 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_4" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 5 duration: 7.26s +INFO:asl3_wx_announce.audio:Playing segment 5 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_5" +INFO:asl3_wx_announce.audio:Playing segment 5 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_5" +INFO:asl3_wx_announce.audio:Pausing 5s for unkey... +INFO:asl3_wx_announce.audio:Segment 6 duration: 6.16s +INFO:asl3_wx_announce.audio:Playing segment 6 on 1966: sudo /usr/sbin/asterisk -rx "rpt playback 1966 asl3_wx_report_6" +INFO:asl3_wx_announce.audio:Playing segment 6 on 62394: sudo /usr/sbin/asterisk -rx "rpt playback 62394 asl3_wx_report_6" 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 + File "/opt/asl3_wx_announce/asl3_wx_announce/main.py", line 376, 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 + File "/opt/asl3_wx_announce/asl3_wx_announce/main.py", line 369, in main + sys.exit(0) + ^^^ +NameError: name 'sys' is not defined +admin@node92394:/opt/asl3_wx_announce $ \ No newline at end of file diff --git a/verify_nodes.py b/verify_nodes.py new file mode 100644 index 0000000..8510a81 --- /dev/null +++ b/verify_nodes.py @@ -0,0 +1,26 @@ +from asl3_wx_announce.narrator import Narrator + +def test_startup(): + config = {'station': {'callsign': 'TEST', 'report_style': 'quick'}} + narrator = Narrator(config) + + # Test case: node 1966 + city = "TestCity" + interval = 10 + nodes = ["1966", "2020"] + source = "Test Source" + + msg = narrator.get_startup_message(city, interval, nodes, source) + print("Startup Message:") + print(msg) + + expected_snippet_1 = "1 9 6 6" + expected_snippet_2 = "2 0 2 0" + + if expected_snippet_1 in msg and expected_snippet_2 in msg: + print("\nSUCCESS: Nodes are formatted as digits.") + else: + print("\nFAILURE: Nodes are NOT formatted correctly.") + +if __name__ == "__main__": + test_startup() diff --git a/verify_report.py b/verify_report.py new file mode 100644 index 0000000..cd4c443 --- /dev/null +++ b/verify_report.py @@ -0,0 +1,43 @@ +from asl3_wx_announce.narrator import Narrator +from asl3_wx_announce.models import LocationInfo, CurrentConditions, WeatherForecast, AlertSeverity +from datetime import datetime + +def test_report(): + config = { + 'station': {'callsign': 'VE2TEST', 'report_style': 'quick'}, + 'alerts': {'min_severity': 'Watch', 'check_interval_minutes': 15} + } + narrator = Narrator(config) + + loc = LocationInfo(latitude=45.0, longitude=-73.0, city="Test City", region="Quebec", country_code="CA", timezone="America/Toronto") + curr = CurrentConditions(temperature=20, humidity=50, wind_speed=10, wind_direction="N", description="Sunny") + fc = [WeatherForecast(period_name="Today", high_temp=25, summary="Sunny all day", short_summary="Sunny")] + + # Test 1: Full Report with SFI and Status + report_cfg = { + 'conditions': True, + 'forecast': True, + 'forecast_verbose': False, + 'astro': True, + 'solar_flux': True, # Should fail gracefully or print nothing if no network + 'status': True + } + + print("\n--- Test Report (All Enabled) ---") + msg = narrator.build_full_report(loc, curr, fc, [], sun_info="Sunrise at 6am", report_config=report_cfg) + print(msg) + + # Test 2: Minimal + report_cfg_min = { + 'conditions': True, + 'forecast': False, + 'astro': False, + 'solar_flux': False, + 'status': False + } + print("\n--- Test Report (Minimal) ---") + msg2 = narrator.build_full_report(loc, curr, fc, [], sun_info="Sunrise at 6am", report_config=report_cfg_min) + print(msg2) + +if __name__ == "__main__": + test_report()