Enhancement: Hourly reports, Solar Flux, Time Error checks, and Deploy script updates

main
swanie98635 2 months ago
parent be33a0a063
commit 47387ce05c

@ -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.
@ -15,8 +15,11 @@ It provides **automated verbal announcements** for:
* 🇨🇦 **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

@ -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

@ -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"
startup_text = narrator.get_startup_message(city_name, interval_mins)
# Get Hourly Config
hourly_cfg = config.get('station', {}).get('hourly_report', {})
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)

@ -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")
region_full = states_map.get(region_clean, loc.region)
# Intro
# 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}.")
# State Expansion (Done above)
# 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]")
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]")
# Conditions
if report_config.get('conditions', True):
parts.append(self.announce_conditions(loc, current) + " Break. [PAUSE]")
parts.append(self.announce_forecast(forecast, loc))
if sun_info:
# 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))
# 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:

@ -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:

@ -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"

@ -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 ""

@ -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('<urllib3.connection.HTTPConnection object at 0x7f7067e550>: Failed to establish a new connection: [Errno -2] Name or service not known'))
PS C:\Users\eric\.gemini\antigravity\scratch\Alert ASL>
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 ~7090: 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 ~90120: 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 ~2124 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 ~120150: 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 130150 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 150200 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 200300 range are usually seen only for relatively short periods during the solar maximum, but they truly light up the ionosphere.

@ -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: <class 'env_canada.ec_weather.MetaData'>
INFO:asl3_wx_announce.provider.ec:DEBUG: ec.metadata content: MetaData(attribution='Data provided by Environment Canada', timestamp=datetime.datetime(2026, 1, 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 "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 260, in <module>
File "/opt/asl3_wx_announce/asl3_wx_announce/main.py", line 376, in <module>
main()
File "/home/admin/asl3_wx_announce/asl3_wx_announce/main.py", line 246, in main
setup_logging(config) # Call the placeholder setup_logging
^^^^^^^^^^^^^
NameError: name 'setup_logging' is not defined
admin@node92394:~/asl3_wx_announce $
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 $

@ -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()

@ -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()
Loading…
Cancel
Save

Powered by TurnKey Linux.