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.
606 lines
31 KiB
606 lines
31 KiB
from datetime import datetime
|
|
from typing import List
|
|
import subprocess
|
|
import re
|
|
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
|
|
|
|
class Narrator:
|
|
STRINGS = {
|
|
'en': {
|
|
'current_conditions_intro': "Current conditions for {city}, {region}.",
|
|
'temperature': "The temperature is {val} {unit}.",
|
|
'conditions': "Conditions are {desc}{wind}.",
|
|
'wind_with_dir': ", with winds from the {dir} at {val} {unit}",
|
|
'wind_no_dir': ", with winds at {val} {unit}",
|
|
'forecast_intro': "Here is the forecast.",
|
|
'forecast_item': "{period}: {cond}.{temp}",
|
|
'no_alerts': "There are no active weather alerts.",
|
|
'active_alerts_count': "There are {count} active weather alerts. ",
|
|
'alert_issued': "A {title} was issued at {time}. ",
|
|
'alert_effect': "A {title} is in effect. ",
|
|
'alert_effective_from': "It is effective from {time}. ",
|
|
'alert_expires': "It expires at {time}. ",
|
|
'clear': "{callsign} Clear.",
|
|
'time_msg': "The time is {time} {tz}. Time provided by the {agency}.",
|
|
'automated_report': "This is the automated weather report for {city}, {region}. Break. [PAUSE]",
|
|
'please_advise': "Please be advised: {alerts} Break. [PAUSE]",
|
|
'system_status': "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': "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.",
|
|
'data_source_us': "Data provided by the National Weather Service and the National Institute of Standards and Technology.",
|
|
'data_source_ca': "Data provided by Environment Canada, National Research Council Canada, and the National Alert Aggregation and Dissemination System.",
|
|
'intro_cq': "CQ CQ CQ. This is {callsign} with the updated weather report.",
|
|
'intro_good_day': "Good day.",
|
|
'concludes': "This concludes the weather report for {city}. {callsign} Clear.",
|
|
'degrees': "degrees",
|
|
'degrees_celsius': "degrees celsius",
|
|
'mph': "miles per hour",
|
|
'kph': "kilometers per hour",
|
|
'high': " High",
|
|
'low': " Low",
|
|
# Test & Monitor Strings - (Simplified for brevity, can add more)
|
|
'test_preamble': "{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.",
|
|
'test_message': "This is a test message. This is a sample emergency test message. This is only a test. No action is required.",
|
|
'test_postamble': "{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.",
|
|
'monitor_online': "{callsign} Automatic Monitoring Online. Data provided by {source}. {nodes}Current location {city}. {time_msg} Active Alert Check Interval {interval} minutes. Dynamic Alert Check Interval {active} minutes.",
|
|
'interval_change_active': "{callsign} Notification. The monitoring interval is being changed to {interval} minutes due to active alerts in the area.",
|
|
'interval_change_normal': "{callsign} Notification. Active alerts have expired. The monitoring interval is being changed back to {interval} minutes."
|
|
},
|
|
'fr': {
|
|
'current_conditions_intro': "Conditions actuelles pour {city}, {region}.",
|
|
'temperature': "La température est de {val} {unit}.",
|
|
'conditions': "Les conditions sont {desc}{wind}.",
|
|
'wind_with_dir': ", avec des vents du {dir} à {val} {unit}",
|
|
'wind_no_dir': ", avec des vents à {val} {unit}",
|
|
'forecast_intro': "Voici les prévisions.",
|
|
'forecast_item': "{period}: {cond}.{temp}",
|
|
'no_alerts': "Il n'y a aucune alerte météo active.",
|
|
'active_alerts_count': "Il y a {count} alertes météo actives. ",
|
|
'alert_issued': "Une {title} a été émise à {time}. ",
|
|
'alert_effect': "Une {title} est en vigueur. ",
|
|
'alert_effective_from': "Elle est en vigueur à partir de {time}. ",
|
|
'alert_expires': "Elle expire à {time}. ",
|
|
'clear': "{callsign} Terminé.",
|
|
'time_msg': "Il est {time} {tz}. Heure fournie par {agency}.",
|
|
'automated_report': "Ceci est le rapport météo automatisé pour {city}, {region}. Pause. [PAUSE]",
|
|
'please_advise': "Veuillez noter : {alerts} Pause. [PAUSE]",
|
|
'system_status': "État du système : Surveillance des événements de niveau {sev} et supérieurs. Alertes sonores actives pour les événements {tone_sev} et supérieurs. L'intervalle de surveillance est de {interval} minutes. Pause. [PAUSE]",
|
|
'code_source': "A S L 3 underscore w x underscore announce est disponible sur github et développé par N 7 X O B comme code non commercial fourni au bénéfice de la communauté radioamateur.",
|
|
'data_source_us': "Données fournies par le National Weather Service et le National Institute of Standards and Technology.",
|
|
'data_source_ca': "Données fournies par Environnement Canada, le Conseil national de recherches du Canada et le Système national d'agrégation et de diffusion des alertes.",
|
|
'intro_cq': "CQ CQ CQ. Ici {callsign} avec le rapport météo mis à jour.",
|
|
'intro_good_day': "Bonjour.",
|
|
'concludes': "Ceci conclut le rapport météo pour {city}. {callsign} Terminé.",
|
|
'degrees': "degrés",
|
|
'degrees_celsius': "degrés celsius",
|
|
'mph': "milles par heure",
|
|
'kph': "kilomètres par heure",
|
|
'high': " Maximum",
|
|
'low': " Minimum",
|
|
# Test & Monitor Strings (French translations approx)
|
|
'test_preamble': "{callsign} Essai. Ceci est un test d'alerte d'un système de notification et de message automatisé. Ne prenez aucune mesure suite au message suivant. Je répète - ne prenez aucune mesure - ceci n'est qu'un test. Les tonalités d'essai suivront dans 10 secondes.",
|
|
'test_message': "Ceci est un message de test. Ceci est un exemple de message d'urgence. Ceci n'est qu'un test. Aucune action n'est requise.",
|
|
'test_postamble': "{callsign} vous rappelle que ceci était un test. Ne prenez aucune mesure. Je répète - ne prenez aucune mesure. S'il s'agissait d'une urgence réelle, la tonalité précédente aurait été suivie d'informations spécifiques des autorités officielles sur une urgence imminente nécessitant votre attention immédiate et éventuellement une action pour prévenir la perte de vie, des blessures ou des dommages matériels. Ce test est terminé.",
|
|
'monitor_online': "{callsign} Surveillance automatique en ligne. Données fournies par {source}. {nodes}Emplacement actuel {city}. {time_msg} Intervalle de vérification des alertes actives {interval} minutes. Intervalle de vérification dynamique {active} minutes.",
|
|
'interval_change_active': "{callsign} Notification. L'intervalle de surveillance est modifié à {interval} minutes en raison d'alertes actives dans la région.",
|
|
'interval_change_normal': "{callsign} Notification. Les alertes actives ont expiré. L'intervalle de surveillance revient à {interval} minutes."
|
|
}
|
|
}
|
|
|
|
def __init__(self, config: dict = None):
|
|
self.config = config or {}
|
|
# Determine language (default en)
|
|
self.lang = self.config.get('station', {}).get('announce_language', 'en')
|
|
if self.lang not in self.STRINGS:
|
|
self.lang = 'en'
|
|
|
|
def _get_str(self, key: str, **kwargs) -> str:
|
|
tmpl = self.STRINGS.get(self.lang, {}).get(key, self.STRINGS['en'].get(key, ""))
|
|
return tmpl.format(**kwargs)
|
|
|
|
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"
|
|
}
|
|
# French Directions (TODO: Complete list or keep EN for now if simple)
|
|
# For now, simplistic approach: if lang is FR, we might need a separate map or just rely on EN abbreviations being understood or adding translation map.
|
|
# Let's add basic map for FR if needed, or just let it pass for now.
|
|
|
|
if direction and direction in dirs:
|
|
direction = dirs[direction]
|
|
|
|
# Wind Speed Logic
|
|
speed_val = int(current.wind_speed)
|
|
speed_unit = self._get_str('kph')
|
|
if is_us:
|
|
speed_val = int(current.wind_speed * 0.621371)
|
|
speed_unit = self._get_str('mph')
|
|
|
|
if direction:
|
|
wind = self._get_str('wind_with_dir', dir=direction, val=speed_val, unit=speed_unit)
|
|
else:
|
|
wind = self._get_str('wind_no_dir', val=speed_val, unit=speed_unit)
|
|
|
|
return (
|
|
self._get_str('current_conditions_intro', city=loc.city, region=loc.region) + " " +
|
|
self._get_str('temperature', val=temp_val, unit=self._get_str(temp_unit)) + " " + # temp_unit is key 'degrees' or 'degrees_celsius' now
|
|
self._get_str('conditions', desc=current.description, wind=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 = self._get_str('forecast_intro') + " "
|
|
|
|
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 = self._get_str('high') + f" {int(val)}."
|
|
elif f.low_temp is not None:
|
|
val = f.low_temp
|
|
if is_us: val = (val * 9/5) + 32
|
|
temp = self._get_str('low') + f" {int(val)}."
|
|
|
|
# Always prefer short_summary if available for the new style
|
|
condition = f.short_summary if f.short_summary else f.summary
|
|
|
|
text += self._get_str('forecast_item', period=f.period_name, cond=condition, temp=temp) + " Break. [PAUSE] "
|
|
|
|
return text
|
|
|
|
def announce_alerts(self, alerts: List[WeatherAlert], loc: LocationInfo = None) -> str:
|
|
if not alerts:
|
|
return self._get_str('no_alerts')
|
|
|
|
text = self._get_str('active_alerts_count', count=len(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
|
|
if issued_str:
|
|
text += self._get_str('alert_issued', title=a.title, time=issued_str)
|
|
else:
|
|
text += self._get_str('alert_effect', title=a.title)
|
|
|
|
# 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
|
|
text += self._get_str('alert_effective_from', time=eff_str)
|
|
|
|
# Expires
|
|
text += self._get_str('alert_expires', time=exp_str)
|
|
|
|
# Add sign-off to alerts
|
|
if loc:
|
|
full_callsign = self.get_full_callsign(loc)
|
|
text += " " + self._get_str('clear', callsign=full_callsign)
|
|
else:
|
|
# Fallback if no loc passed (though we should always pass it)
|
|
callsign = self.config.get('station', {}).get('callsign')
|
|
if callsign: text += " " + self._get_str('clear', callsign=callsign)
|
|
|
|
return text
|
|
|
|
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 get_full_callsign(self, loc: LocationInfo) -> str:
|
|
"""
|
|
Determines the portable suffix based on location.
|
|
Returns the formatted string e.g. "N7XOB Portable V E 3"
|
|
"""
|
|
callsign = self.config.get('station', {}).get('callsign')
|
|
if not callsign or not loc.country_code:
|
|
return callsign or "Station"
|
|
|
|
upper_call = callsign.upper()
|
|
country = loc.country_code.upper()
|
|
region = loc.region.upper().strip() if loc.region else ""
|
|
|
|
# Region Normalization Map (Full Name -> Abbrev)
|
|
# Providers (like reverse_geocoder) may return full names
|
|
region_map = {
|
|
# Canada
|
|
"BRITISH COLUMBIA": "BC", "ALBERTA": "AB", "SASKATCHEWAN": "SK", "MANITOBA": "MB",
|
|
"ONTARIO": "ON", "QUEBEC": "QC", "QUÉBEC": "QC", "NEW BRUNSWICK": "NB", "NOVA SCOTIA": "NS",
|
|
"PRINCE EDWARD ISLAND": "PE", "NEWFOUNDLAND AND LABRADOR": "NL", "NEWFOUNDLAND": "NL",
|
|
"YUKON": "YT", "NORTHWEST TERRITORIES": "NT", "NUNAVUT": "NU",
|
|
# US (Common ones, extend as needed)
|
|
"ALABAMA": "AL", "ALASKA": "AK", "ARIZONA": "AZ", "ARKANSAS": "AR", "CALIFORNIA": "CA",
|
|
"COLORADO": "CO", "CONNECTICUT": "CT", "DELAWARE": "DE", "FLORIDA": "FL", "GEORGIA": "GA",
|
|
"HAWAII": "HI", "IDAHO": "ID", "ILLINOIS": "IL", "INDIANA": "IN", "IOWA": "IA",
|
|
"KANSAS": "KS", "KENTUCKY": "KY", "LOUISIANA": "LA", "MAINE": "ME", "MARYLAND": "MD",
|
|
"MASSACHUSETTS": "MA", "MICHIGAN": "MI", "MINNESOTA": "MN", "MISSISSIPPI": "MS", "MISSOURI": "MO",
|
|
"MONTANA": "MT", "NEBRASKA": "NE", "NEVADA": "NV", "NEW HAMPSHIRE": "NH", "NEW JERSEY": "NJ",
|
|
"NEW MEXICO": "NM", "NEW YORK": "NY", "NORTH CAROLINA": "NC", "NORTH DAKOTA": "ND", "OHIO": "OH",
|
|
"OKLAHOMA": "OK", "OREGON": "OR", "PENNSYLVANIA": "PA", "RHODE ISLAND": "RI", "SOUTH CAROLINA": "SC",
|
|
"SOUTH DAKOTA": "SD", "TENNESSEE": "TN", "TEXAS": "TX", "UTAH": "UT", "VERMONT": "VT",
|
|
"VIRGINIA": "VA", "WASHINGTON": "WA", "WEST VIRGINIA": "WV", "WISCONSIN": "WI", "WYOMING": "WY",
|
|
"DISTRICT OF COLUMBIA": "DC"
|
|
}
|
|
|
|
# Normalize Region
|
|
if region in region_map:
|
|
region = region_map[region]
|
|
|
|
# Handle accented characters if not caught by map (though we added QUÉBEC above)
|
|
# This catch-all helps if there are other variations
|
|
if region == "QUÉBEC":
|
|
region = "QC"
|
|
|
|
# Determine Origin (Simple heuristic: K,N,W,A=US; V,C=CA)
|
|
origin_country = "US"
|
|
# Callsigns starting with V, C, CY, VO, VY are Canadian
|
|
if upper_call[0] in ['V', 'C'] or upper_call.startswith('VE') or upper_call.startswith('VA') or upper_call.startswith('VO') or upper_call.startswith('VY'):
|
|
origin_country = "CA"
|
|
|
|
suffix = ""
|
|
|
|
# 1. Canadian Station in US -> "Portable [Zone]"
|
|
if origin_country == "CA" and country == "US":
|
|
# US Call Zones
|
|
zones = {
|
|
'1': ['CT', 'MA', 'ME', 'NH', 'RI', 'VT'],
|
|
'2': ['NJ', 'NY'],
|
|
'3': ['DE', 'DC', 'MD', 'PA'],
|
|
'4': ['AL', 'FL', 'GA', 'KY', 'NC', 'SC', 'TN', 'VA'],
|
|
'5': ['AR', 'LA', 'MS', 'NM', 'OK', 'TX'],
|
|
'6': ['CA', 'HI'], # HI is KH6, often treated as 6 for this context
|
|
'7': ['AZ', 'ID', 'MT', 'NV', 'OR', 'UT', 'WA', 'WY', 'AK'], # AK is KL7
|
|
'8': ['MI', 'OH', 'WV'],
|
|
'9': ['IL', 'IN', 'WI'],
|
|
'0': ['CO', 'IA', 'KS', 'MN', 'MO', 'NE', 'ND', 'SD']
|
|
}
|
|
|
|
zone_num = None
|
|
for z, states in zones.items():
|
|
if region in states:
|
|
zone_num = z
|
|
break
|
|
|
|
if zone_num:
|
|
suffix = f" Portable N {zone_num}"
|
|
else:
|
|
suffix = " Portable U S"
|
|
|
|
# 2. US Station in CA -> "Portable [Prefix] [Zone]"
|
|
elif origin_country == "US" and country == "CA":
|
|
# Canadian Prefixes Map
|
|
provinces = {
|
|
'BC': 'V E 7', 'AB': 'V E 6', 'SK': 'V E 5', 'MB': 'V E 4',
|
|
'ON': 'V E 3', 'QC': 'V E 2', 'NB': 'V E 9', 'NS': 'V E 1',
|
|
'PE': 'V Y 2', 'NL': 'V O 1', 'YT': 'V Y 1', 'NT': 'V E 8',
|
|
'NU': 'V Y 0'
|
|
}
|
|
|
|
# Fallback mappings for standard zones if precise prefix unknown
|
|
fallback_map = {
|
|
'BC': '7', 'AB': '6', 'SK': '5', 'MB': '4', 'ON': '3', 'QC': '2',
|
|
'NB': '1', 'NS': '1', 'PE': '1', 'NL': '1', 'YT': '1', 'NT': '8', 'NU': '0'
|
|
}
|
|
|
|
prefix_str = provinces.get(region)
|
|
if not prefix_str:
|
|
# Try fallback generic VE#
|
|
digit = fallback_map.get(region)
|
|
if digit:
|
|
prefix_str = f"V E {digit}"
|
|
|
|
if prefix_str:
|
|
suffix = f" Portable {prefix_str}"
|
|
else:
|
|
suffix = " Portable Canada"
|
|
|
|
return f"{callsign}{suffix}"
|
|
|
|
|
|
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_raw = self.config.get('station', {}).get('callsign')
|
|
|
|
if callsign_raw:
|
|
full_callsign = self.get_full_callsign(loc)
|
|
parts.append(self._get_str('intro_cq', callsign=full_callsign))
|
|
else:
|
|
parts.append(self._get_str('intro_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'
|
|
}
|
|
# French TZ map?
|
|
# For now keep English TZ names or basic generic.
|
|
|
|
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(self._get_str('time_msg', time=now_str, tz=full_tz, agency=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(self._get_str('automated_report', city=loc.city, region=region_full))
|
|
|
|
if alerts:
|
|
parts.append(self._get_str('please_advise', alerts=self.announce_alerts(alerts, 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))
|
|
|
|
# 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(self._get_str('system_status', sev=sev, tone_sev=tone_sev, interval=interval))
|
|
|
|
# Code Source
|
|
if report_config.get('code_source', False):
|
|
parts.append(self._get_str('code_source'))
|
|
|
|
# Agency Credits
|
|
if loc.country_code == 'US':
|
|
parts.append(self._get_str('data_source_us'))
|
|
elif loc.country_code == 'CA':
|
|
parts.append(self._get_str('data_source_ca'))
|
|
|
|
parts.append("Break. [PAUSE]")
|
|
|
|
# Outro
|
|
parts.append(self._get_str('concludes', city=loc.city, callsign=full_callsign))
|
|
|
|
return " ".join(parts)
|
|
|
|
def get_test_preamble(self, loc: LocationInfo = None) -> str:
|
|
if loc:
|
|
callsign = self.get_full_callsign(loc)
|
|
else:
|
|
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
|
|
|
|
return self._get_str('test_preamble', callsign=callsign)
|
|
|
|
def get_test_message(self) -> str:
|
|
return self._get_str('test_message')
|
|
|
|
def get_test_postamble(self, loc: LocationInfo = None) -> str:
|
|
if loc:
|
|
callsign = self.get_full_callsign(loc)
|
|
else:
|
|
callsign = self.config.get('station', {}).get('callsign', 'Amateur Radio')
|
|
|
|
return self._get_str('test_postamble', callsign=callsign)
|
|
|
|
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.get_full_callsign(loc)
|
|
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}"
|
|
|
|
# Using simpler localization for startup message to avoid overly complex templating
|
|
msg = self._get_str('monitor_online', callsign=callsign, source=source_name, nodes=node_str, city=city, time_msg=time_msg, interval=interval, active=active_interval)
|
|
|
|
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. "
|
|
# TODO: Localize hourly report content listing if needed
|
|
|
|
return msg
|
|
|
|
def get_interval_change_message(self, interval_mins: int, active_alerts: bool, loc: LocationInfo = None) -> str:
|
|
if loc:
|
|
callsign = self.get_full_callsign(loc)
|
|
else:
|
|
callsign = self.config.get('station', {}).get('callsign', 'Station')
|
|
|
|
if active_alerts:
|
|
return self._get_str('interval_change_active', callsign=callsign, interval=interval_mins)
|
|
else:
|
|
return self._get_str('interval_change_normal', callsign=callsign, interval=interval_mins)
|