You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
352 lines
14 KiB
352 lines
14 KiB
import argparse
|
|
import yaml
|
|
import time
|
|
import logging
|
|
import subprocess
|
|
from datetime import datetime
|
|
from .models import AlertSeverity
|
|
from .location import LocationService
|
|
from .provider.factory import get_provider_instance
|
|
from .provider.astro import AstroProvider
|
|
from .narrator import Narrator
|
|
from .audio import AudioHandler
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger("asl3_wx")
|
|
|
|
def wait_for_asterisk(timeout=120):
|
|
"""
|
|
Polls Asterisk until it is ready to accept commands.
|
|
"""
|
|
logger.info("Waiting for Asterisk to be fully booted...")
|
|
start_time = time.time()
|
|
while (time.time() - start_time) < timeout:
|
|
try:
|
|
# Check if Asterisk is running and accepting CLI commands
|
|
# 'core waitfullybooted' ensures modules are loaded
|
|
subprocess.run("sudo /usr/sbin/asterisk -rx 'core waitfullybooted'",
|
|
shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
logger.info("Asterisk is ready.")
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
time.sleep(2)
|
|
|
|
logger.error("Timeout waiting for Asterisk.")
|
|
return False
|
|
|
|
def load_config(path):
|
|
with open(path, 'r') as f:
|
|
return yaml.safe_load(f)
|
|
|
|
def setup_logging(config):
|
|
"""
|
|
Configure logging based on config settings.
|
|
"""
|
|
level = logging.INFO
|
|
# If user wants debug, they can set it in config (not implemented yet, defaulting to INFO)
|
|
logging.basicConfig(
|
|
level=level,
|
|
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
# Silence noisy libs if needed
|
|
logging.getLogger("requests").setLevel(logging.WARNING)
|
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
|
|
def do_full_report(config):
|
|
loc_svc = LocationService(config)
|
|
lat, lon = loc_svc.get_coordinates()
|
|
|
|
# Provider
|
|
# Provider
|
|
prov_code = config.get('location', {}).get('provider')
|
|
provider = get_provider_instance(CountryCode=prov_code, Lat=lat, Lon=lon, Config=config)
|
|
|
|
# Auto-Timezone
|
|
source = config.get('location', {}).get('source', 'fixed')
|
|
cfg_tz = config.get('location', {}).get('timezone')
|
|
|
|
# Fetch Data
|
|
loc_info = provider.get_location_info(lat, lon)
|
|
|
|
# Auto-Timezone Logic
|
|
# 1. Prefer Provider's detected timezone (e.g. NWS provides it)
|
|
if loc_info.timezone and loc_info.timezone not in ['UTC', 'Unknown']:
|
|
logger.info(f"Using Provider Timezone: {loc_info.timezone}")
|
|
if 'location' not in config: config['location'] = {}
|
|
config['location']['timezone'] = loc_info.timezone
|
|
|
|
# 2. Fallback to Config
|
|
elif cfg_tz:
|
|
logger.info(f"Using Config Timezone: {cfg_tz}")
|
|
|
|
# 3. Last Resort: UTC (or maybe simple offset lookup if we implemented one)
|
|
else:
|
|
logger.warning("No timezone found! Defaulting to UTC.")
|
|
|
|
# Manual City Override
|
|
manual_city = config.get('location', {}).get('city')
|
|
if manual_city:
|
|
loc_info.city = manual_city
|
|
|
|
logger.info(f"Resolved Location: {loc_info.city}, {loc_info.region} ({loc_info.country_code})")
|
|
|
|
conditions = provider.get_conditions(lat, lon)
|
|
forecast = provider.get_forecast(lat, lon)
|
|
alerts = provider.get_alerts(lat, lon)
|
|
|
|
# Astro
|
|
astro = AstroProvider()
|
|
sun_info = astro.get_astro_info(loc_info)
|
|
|
|
# Narrate
|
|
narrator = Narrator(config)
|
|
text = narrator.build_full_report(loc_info, conditions, forecast, alerts, sun_info)
|
|
logger.info(f"Report Text: {text}")
|
|
|
|
# Audio
|
|
handler = AudioHandler(config)
|
|
wav_files = handler.generate_audio(text, "report.gsm")
|
|
|
|
# Play
|
|
nodes = config.get('voice', {}).get('nodes', [])
|
|
handler.play_on_nodes(wav_files, nodes)
|
|
|
|
def monitor_loop(config):
|
|
normal_interval = config.get('alerts', {}).get('check_interval_minutes', 10) * 60
|
|
current_interval = normal_interval
|
|
do_hourly = config.get('station', {}).get('hourly_report', False)
|
|
|
|
known_alerts = set()
|
|
|
|
# Initialize service objects
|
|
loc_svc = LocationService(config)
|
|
lat, lon = loc_svc.get_coordinates()
|
|
prov_code = config.get('location', {}).get('provider')
|
|
provider = get_provider_instance(CountryCode=prov_code, Lat=lat, Lon=lon, Config=config)
|
|
narrator = Narrator(config)
|
|
handler = AudioHandler(config)
|
|
nodes = config.get('voice', {}).get('nodes', [])
|
|
|
|
# Initialize AlertReady (Optional)
|
|
ar_provider = None
|
|
if config.get('alerts', {}).get('enable_alert_ready', False):
|
|
try:
|
|
# Lazy import
|
|
from .provider.alert_ready import AlertReadyProvider
|
|
ar_provider = AlertReadyProvider(alerts=config.get('alerts', {}))
|
|
logger.info("AlertReady Provider Enabled.")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load AlertReadyProvider: {e}")
|
|
|
|
logger.info(f"Starting Alert Monitor... (Hourly Reports: {do_hourly})")
|
|
|
|
# Robust Wait for Asterisk
|
|
if wait_for_asterisk():
|
|
# Give a small buffer after it claims ready
|
|
time.sleep(5)
|
|
|
|
try:
|
|
# Startup Announcement
|
|
# Resolve initial location info for city name
|
|
info = provider.get_location_info(lat, lon)
|
|
city_name = info.city if info.city else "Unknown Location"
|
|
interval_mins = int(normal_interval / 60)
|
|
|
|
startup_text = narrator.get_startup_message(city_name, interval_mins)
|
|
logger.info(f"Startup Announcement: {startup_text}")
|
|
|
|
# Generate and Play
|
|
wav_files = handler.generate_audio(startup_text, filename="startup.gsm")
|
|
if wav_files:
|
|
handler.play_on_nodes(wav_files, nodes)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to play startup announcement: {e}")
|
|
|
|
last_alert_check = 0
|
|
last_report_hour = -1
|
|
|
|
while True:
|
|
now = time.time()
|
|
now_dt = datetime.fromtimestamp(now)
|
|
|
|
# 1. Hourly Report Check
|
|
if do_hourly:
|
|
# Check if it is the top of the hour (minute 0) and we haven't played yet this hour
|
|
if now_dt.minute == 0 and now_dt.hour != last_report_hour:
|
|
logger.info("Triggering Hourly Weather Report...")
|
|
try:
|
|
do_full_report(config)
|
|
last_report_hour = now_dt.hour
|
|
except Exception as e:
|
|
logger.error(f"Hourly Report Failed: {e}")
|
|
|
|
# 2. Alert Check
|
|
if (now - last_alert_check) > current_interval:
|
|
last_alert_check = now
|
|
try:
|
|
logger.info(f"Checking for alerts... (Interval: {current_interval}s)")
|
|
# Fetch Weather Alerts
|
|
alerts = provider.get_alerts(lat, lon)
|
|
|
|
# Fetch AlertReady Alerts
|
|
if ar_provider:
|
|
try:
|
|
ar_alerts = ar_provider.get_alerts(lat, lon)
|
|
if ar_alerts:
|
|
logger.info(f"AlertReady found {len(ar_alerts)} items.")
|
|
alerts.extend(ar_alerts)
|
|
except Exception as e:
|
|
logger.error(f"AlertReady Check Failed: {e}")
|
|
|
|
current_ids = {a.id for a in alerts}
|
|
|
|
new_alerts = []
|
|
for a in alerts:
|
|
if a.id not in known_alerts:
|
|
new_alerts.append(a)
|
|
known_alerts.add(a.id)
|
|
|
|
# Check for Tone Trigger & Dynamic Polling
|
|
tone_config = config.get('alerts', {}).get('alert_tone', {})
|
|
ranks = {'Unknown': 0, 'Advisory': 1, 'Watch': 2, 'Warning': 3, 'Critical': 4}
|
|
|
|
# 1. Determine Max Severity of ALL active alerts for Dynamic Polling
|
|
max_severity_rank = 0
|
|
for a in alerts: # Scan ALL active alerts
|
|
s_val = a.severity.value if hasattr(a.severity, 'value') else str(a.severity)
|
|
rank = ranks.get(s_val, 0)
|
|
if rank > max_severity_rank:
|
|
max_severity_rank = rank
|
|
|
|
# If Watch (2) or Higher, set interval to 1 minute
|
|
new_interval = normal_interval
|
|
active_threat = False
|
|
|
|
if max_severity_rank >= 2:
|
|
new_interval = 60
|
|
active_threat = True
|
|
logger.info("Active Watch/Warning/Critical detected. Requesting 1-minute polling.")
|
|
|
|
# Check for Interval Change
|
|
if new_interval != current_interval:
|
|
logger.info(f"Interval Change Detected: {current_interval} -> {new_interval}")
|
|
current_interval = new_interval
|
|
|
|
mins = int(current_interval / 60)
|
|
msg = narrator.get_interval_change_message(mins, active_threat)
|
|
logger.info(f"Announcing Interval Change: {msg}")
|
|
|
|
try:
|
|
wavs = handler.generate_audio(msg, "interval_change.gsm")
|
|
handler.play_on_nodes(wavs, nodes)
|
|
except Exception as e:
|
|
logger.error(f"Failed to announce interval change: {e}")
|
|
|
|
# 2. Check for Tone Trigger (New Alerts Only)
|
|
if new_alerts and tone_config.get('enabled', False):
|
|
min_sev_str = tone_config.get('min_severity', 'Warning')
|
|
threshold = ranks.get(min_sev_str, 3)
|
|
|
|
should_tone = False
|
|
for a in new_alerts:
|
|
s_val = a.severity.value if hasattr(a.severity, 'value') else str(a.severity)
|
|
if ranks.get(s_val, 0) >= threshold:
|
|
should_tone = True
|
|
break
|
|
|
|
if should_tone:
|
|
logger.info("High severity alert detected. Playing Attention Signal.")
|
|
try:
|
|
tone_file = handler.ensure_alert_tone()
|
|
handler.play_on_nodes([tone_file], nodes)
|
|
except Exception as e:
|
|
logger.error(f"Failed to play alert tone: {e}")
|
|
|
|
# Announce new items
|
|
if new_alerts:
|
|
logger.info(f"New Alerts detected: {len(new_alerts)}")
|
|
text = narrator.announce_alerts(new_alerts)
|
|
wavs = handler.generate_audio(text, "alert.gsm")
|
|
handler.play_on_nodes(wavs, nodes)
|
|
|
|
# Cleanup expired from known
|
|
known_alerts = known_alerts.intersection(current_ids)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Monitor error: {e}")
|
|
|
|
# Check every minute (resolution of the loop)
|
|
time.sleep(60)
|
|
|
|
def do_test_alert(config):
|
|
"""
|
|
Executes the comprehensive Test Alert sequence.
|
|
"""
|
|
logger.info("Executing System Test Alert...")
|
|
|
|
narrator = Narrator(config)
|
|
handler = AudioHandler(config)
|
|
nodes = config.get('voice', {}).get('nodes', [])
|
|
|
|
if not nodes:
|
|
logger.error("No nodes configured for playback.")
|
|
return
|
|
|
|
# 1. Preamble
|
|
logger.info("Playing Preamble...")
|
|
preamble_text = narrator.get_test_preamble()
|
|
files = handler.generate_audio(preamble_text, "test_preamble.gsm")
|
|
handler.play_on_nodes(files, nodes)
|
|
|
|
# 2. Silence (10s unkeyed)
|
|
logger.info("Waiting 10s (Unkeyed)...")
|
|
time.sleep(10)
|
|
|
|
# 3. Alert Tone
|
|
# Check if tone is enabled in config, otherwise force it for test?
|
|
# User said "The preceding tone" in postamble, so we should play it regardless of config setting for the TEST mode.
|
|
logger.info("Playing Alert Tone...")
|
|
tone_file = handler.ensure_alert_tone()
|
|
handler.play_on_nodes([tone_file], nodes)
|
|
|
|
# 4. Test Message
|
|
logger.info("Playing Test Message...")
|
|
msg_text = narrator.get_test_message()
|
|
files = handler.generate_audio(msg_text, "test_message.gsm")
|
|
handler.play_on_nodes(files, nodes)
|
|
|
|
# 5. Postamble
|
|
logger.info("Playing Postamble...")
|
|
post_text = narrator.get_test_postamble()
|
|
files = handler.generate_audio(post_text, "test_postamble.gsm")
|
|
handler.play_on_nodes(files, nodes)
|
|
|
|
logger.info("Test Alert Concluded.")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="ASL3 Weather Announcer")
|
|
parser.add_argument("--config", default="config.yaml", help="Path to config file")
|
|
parser.add_argument("--report", action="store_true", help="Run immediate full weather report")
|
|
parser.add_argument("--monitor", action="store_true", help="Run in continuous monitor mode")
|
|
parser.add_argument("--test-alert", action="store_true", help="Run a comprehensive Test Alert sequence")
|
|
|
|
args = parser.parse_args()
|
|
config = load_config(args.config)
|
|
setup_logging(config) # Call the placeholder setup_logging
|
|
|
|
if args.test_alert:
|
|
do_test_alert(config)
|
|
sys.exit(0)
|
|
elif args.report:
|
|
do_full_report(config)
|
|
sys.exit(0)
|
|
elif args.monitor:
|
|
monitor_loop(config)
|
|
else:
|
|
parser.print_help()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|