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.
441 lines
19 KiB
441 lines
19 KiB
from datetime import datetime
|
|
from typing import List
|
|
import subprocess
|
|
import re
|
|
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
|
|
|
|
class Narrator:
|
|
def __init__(self, config: dict = None):
|
|
self.config = config or {}
|
|
|
|
def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str:
|
|
is_us = (loc.country_code or "").upper() == 'US'
|
|
|
|
# Temp Logic
|
|
temp_val = int(current.temperature)
|
|
temp_unit = "degrees celsius"
|
|
if is_us:
|
|
temp_val = int((current.temperature * 9/5) + 32)
|
|
temp_unit = "degrees" # US usually assumes F
|
|
|
|
wind = ""
|
|
# Low threshold for wind
|
|
if current.wind_speed is not None and current.wind_speed > 2:
|
|
direction = current.wind_direction
|
|
|
|
# Expand abbreviations
|
|
dirs = {
|
|
"N": "North", "NNE": "North Northeast", "NE": "Northeast", "ENE": "East Northeast",
|
|
"E": "East", "ESE": "East Southeast", "SE": "Southeast", "SSE": "South Southeast",
|
|
"S": "South", "SSW": "South Southwest", "SW": "Southwest", "WSW": "West Southwest",
|
|
"W": "West", "WNW": "West Northwest", "NW": "Northwest", "NNW": "North Northwest"
|
|
}
|
|
|
|
if direction and direction in dirs:
|
|
direction = dirs[direction]
|
|
|
|
# Wind Speed Logic
|
|
speed_val = int(current.wind_speed)
|
|
speed_unit = "kilometers per hour"
|
|
if is_us:
|
|
speed_val = int(current.wind_speed * 0.621371)
|
|
speed_unit = "miles per hour"
|
|
|
|
if direction:
|
|
wind = f", with winds from the {direction} at {speed_val} {speed_unit}"
|
|
else:
|
|
wind = f", with winds at {speed_val} {speed_unit}"
|
|
|
|
return (
|
|
f"Current conditions for {loc.city}, {loc.region}. "
|
|
f"The temperature is {temp_val} {temp_unit}. "
|
|
f"Conditions are {current.description}{wind}."
|
|
)
|
|
|
|
def announce_forecast(self, forecasts: List[WeatherForecast], loc: LocationInfo, verbose: bool = True) -> str:
|
|
if not forecasts:
|
|
return ""
|
|
|
|
is_us = (loc.country_code or "").upper() == 'US'
|
|
|
|
text = "Here is the forecast. "
|
|
|
|
limit = 4 if not verbose else len(forecasts)
|
|
|
|
for f in forecasts[:limit]:
|
|
temp = ""
|
|
if f.high_temp is not None:
|
|
val = f.high_temp
|
|
if is_us: val = (val * 9/5) + 32
|
|
temp = f" High {int(val)}."
|
|
elif f.low_temp is not None:
|
|
val = f.low_temp
|
|
if is_us: val = (val * 9/5) + 32
|
|
temp = f" Low {int(val)}."
|
|
|
|
# Always prefer short_summary if available for the new style
|
|
condition = f.short_summary if f.short_summary else f.summary
|
|
|
|
text += f"{f.period_name}: {condition}.{temp} Break. [PAUSE] "
|
|
|
|
return text
|
|
|
|
def announce_alerts(self, alerts: List[WeatherAlert]) -> str:
|
|
if not alerts:
|
|
return "There are no active weather alerts."
|
|
|
|
text = f"There are {len(alerts)} active weather alerts. "
|
|
for a in alerts:
|
|
# Format times
|
|
fmt = "%I %M %p"
|
|
|
|
issued_str = a.issued.strftime(fmt) if a.issued else ""
|
|
eff_str = a.effective.strftime(fmt)
|
|
exp_str = a.expires.strftime(fmt)
|
|
|
|
# Construct message
|
|
item_text = f"A {a.title} "
|
|
if issued_str:
|
|
item_text += f"was issued at {issued_str}. "
|
|
else:
|
|
item_text += "is in effect. "
|
|
|
|
# If effective is different from issued, mention it
|
|
if a.effective and a.issued and abs((a.effective - a.issued).total_seconds()) > 600: # >10 mins diff
|
|
item_text += f"It is effective from {eff_str}. "
|
|
|
|
# Expires
|
|
item_text += f"It expires at {exp_str}. "
|
|
|
|
text += item_text
|
|
|
|
# Add sign-off to alerts
|
|
callsign = self.config.get('station', {}).get('callsign')
|
|
if callsign:
|
|
text += f" {callsign} Clear."
|
|
|
|
return text
|
|
|
|
def 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')
|
|
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:]
|
|
|
|
# State Expansion Map
|
|
states_map = {
|
|
"AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California",
|
|
"CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "FL": "Florida", "GA": "Georgia",
|
|
"HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa",
|
|
"KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
|
|
"MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", "MO": "Missouri",
|
|
"MT": "Montana", "NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey",
|
|
"NM": "New Mexico", "NY": "New York", "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio",
|
|
"OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina",
|
|
"SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont",
|
|
"VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming",
|
|
"DC": "District of Columbia"
|
|
}
|
|
|
|
region_clean = loc.region.upper() if loc.region else ""
|
|
region_full = states_map.get(region_clean, loc.region)
|
|
|
|
# Intro callsign logic
|
|
callsign = self.config.get('station', {}).get('callsign')
|
|
full_callsign = callsign or ""
|
|
|
|
if callsign:
|
|
# Cross-border logic
|
|
suffix = ""
|
|
upper_call = callsign.upper()
|
|
country = loc.country_code.upper() if loc.country_code else ""
|
|
|
|
# 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'):
|
|
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]")
|
|
|
|
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]")
|
|
|
|
# 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]")
|
|
|
|
# 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)
|
|
|
|
def get_test_preamble(self) -> str:
|
|
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
|
|
return (
|
|
f"{callsign} Testing. The following is an alerting test of an automated alerting notification and message. "
|
|
"Do not take any action as a result of the following message. "
|
|
"Repeating - take no action - this is only a test. "
|
|
"The test tones will follow in 10 seconds."
|
|
)
|
|
|
|
def get_test_message(self) -> str:
|
|
return (
|
|
"This is a test message. "
|
|
"This is a sample emergency test message. "
|
|
"This is only a test. No action is required."
|
|
)
|
|
|
|
def get_test_postamble(self) -> str:
|
|
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
|
|
return (
|
|
f"{callsign} reminds you this was a test. "
|
|
"Do not take action. Repeat - take no action. "
|
|
"If this was an actual emergency, the preceding tone would be followed by specific information "
|
|
"from official authorities on an imminent emergency that requires your immediate attention "
|
|
"and possibly action to prevent loss of life, injury or property damage. "
|
|
"This test is concluded."
|
|
)
|
|
|
|
|
|
def get_startup_message(self, 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')
|
|
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"{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:
|
|
return f"{callsign} Notification. The monitoring interval is being changed to {interval_mins} minute{'s' if interval_mins!=1 else ''} due to active alerts in the area."
|
|
else:
|
|
return f"{callsign} Notification. Active alerts have expired. The monitoring interval is being changed back to {interval_mins} minutes."
|