Initial release of ASL3 Weather Announcer (v1.0)

main
swanie98635 5 months ago
parent f46e53000d
commit c5eb5dba4f

4
.gitignore vendored

@ -18,3 +18,7 @@ asl3_wx_announce/config.yaml
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
venv/
.idea/

@ -9,7 +9,8 @@ class AudioHandler:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Default to echo if no TTS configured (just for safety) # Default to echo if no TTS configured (just for safety)
self.tts_template = config.get('voice', {}).get('tts_command', 'echo "{text}" > {file}') self.tts_template = config.get('voice', {}).get('tts_command', 'echo "{text}" > {file}')
self.output_dir = "/tmp/asl3_wx" # Use standard Asterisk sounds directory
self.output_dir = "/var/lib/asterisk/sounds/asl3_wx_announce"
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
def generate_audio(self, text: str, filename: str = "announcement.wav") -> str: def generate_audio(self, text: str, filename: str = "announcement.wav") -> str:
@ -30,9 +31,8 @@ class AudioHandler:
raise e raise e
def play_on_nodes(self, filepath: str, nodes: List[str]): def play_on_nodes(self, filepath: str, nodes: List[str]):
# Asterisk uses file path WITHOUT extension usually for play commands, # Asterisk uses file path WITHOUT extension usually for play commands.
# but rpt localplay might require full path or specific format. # KD5FMU script uses absolute path successfully (e.g. /tmp/current-time)
# Usually: rpt localplay <node> <path_no_ext>
path_no_ext = os.path.splitext(filepath)[0] path_no_ext = os.path.splitext(filepath)[0]
for node in nodes: for node in nodes:

@ -43,7 +43,7 @@ def do_full_report(config):
# Audio # Audio
handler = AudioHandler(config) handler = AudioHandler(config)
wav_file = handler.generate_audio(text, "report.wav") wav_file = handler.generate_audio(text, "report.gsm")
# Play # Play
nodes = config.get('audio', {}).get('nodes', []) nodes = config.get('audio', {}).get('nodes', [])
@ -78,7 +78,7 @@ def monitor_loop(config):
if new_alerts: if new_alerts:
logger.info(f"New Alerts detected: {len(new_alerts)}") logger.info(f"New Alerts detected: {len(new_alerts)}")
text = narrator.announce_alerts(new_alerts) text = narrator.announce_alerts(new_alerts)
wav = handler.generate_audio(text, "alert.wav") wav = handler.generate_audio(text, "alert.gsm")
handler.play_on_nodes(wav, nodes) handler.play_on_nodes(wav, nodes)
# Cleanup expired from known # Cleanup expired from known

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import pytz
from typing import List from typing import List
from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert from .models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert
@ -6,14 +7,19 @@ class Narrator:
def __init__(self): def __init__(self):
pass pass
def _c_to_f(self, temp_c: float) -> int:
return int((temp_c * 9/5) + 32)
def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str: def announce_conditions(self, loc: LocationInfo, current: CurrentConditions) -> str:
wind = "" wind = ""
if current.wind_speed and current.wind_speed > 5: if current.wind_speed and current.wind_speed > 5:
wind = f", with winds from the {current.wind_direction} at {int(current.wind_speed)} kilometers per hour" # Describe wind speed in km/h and mph
mph = int(current.wind_speed * 0.621371)
wind = f", with winds from the {current.wind_direction} at {int(current.wind_speed)} kilometers per hour, or {mph} miles per hour"
return ( return (
f"Current conditions for {loc.city}, {loc.region}. " f"Current conditions for {loc.city}, {loc.region}. "
f"The temperature is {int(current.temperature)} degrees celsius. " f"The temperature is {int(current.temperature)} degrees celsius, {self._c_to_f(current.temperature)} degrees fahrenheit. "
f"Conditions are {current.description}{wind}." f"Conditions are {current.description}{wind}."
) )
@ -25,9 +31,9 @@ class Narrator:
for f in forecasts[:3]: # Read first 3 periods for f in forecasts[:3]: # Read first 3 periods
temp = "" temp = ""
if f.high_temp is not None: if f.high_temp is not None:
temp = f" with a high of {int(f.high_temp)}" temp = f" with a high of {int(f.high_temp)} celsius, {self._c_to_f(f.high_temp)} fahrenheit"
elif f.low_temp is not None: elif f.low_temp is not None:
temp = f" with a low of {int(f.low_temp)}" temp = f" with a low of {int(f.low_temp)} celsius, {self._c_to_f(f.low_temp)} fahrenheit"
text += f"{f.period_name}: {f.summary}{temp}. " text += f"{f.period_name}: {f.summary}{temp}. "
@ -48,8 +54,16 @@ class Narrator:
def build_full_report(self, loc: LocationInfo, current: CurrentConditions, forecast: List[WeatherForecast], alerts: List[WeatherAlert], sun_info: str = "") -> str: def build_full_report(self, loc: LocationInfo, current: CurrentConditions, forecast: List[WeatherForecast], alerts: List[WeatherAlert], sun_info: str = "") -> str:
parts = [] parts = []
# Localize time
try:
tz = pytz.timezone(loc.timezone)
now = datetime.now(tz)
except Exception:
now = datetime.now()
# Time string: "10 30 PM" # Time string: "10 30 PM"
now_str = datetime.now().strftime("%I %M %p") now_str = now.strftime("%I %M %p")
# Remove leading zero from hour if desired, but TTS usually handles "09" fine. # Remove leading zero from hour if desired, but TTS usually handles "09" fine.
# For cleaner TTS: # For cleaner TTS:
if now_str.startswith("0"): if now_str.startswith("0"):

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from env_canada import ECData from env_canada import ECWeather
from ..models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert, AlertSeverity from ..models import LocationInfo, CurrentConditions, WeatherForecast, WeatherAlert, AlertSeverity
from .base import WeatherProvider from .base import WeatherProvider
@ -10,8 +10,8 @@ class ECProvider(WeatherProvider):
self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', []) self.extra_zones = kwargs.get('alerts', {}).get('extra_zones', [])
def _get_ec_data(self, lat, lon): def _get_ec_data(self, lat, lon):
# ECData auto-selects station based on lat/lon # ECWeather auto-selects station based on lat/lon
ec = ECData(coordinates=(lat, lon)) ec = ECWeather(coordinates=(lat, lon))
ec.update() ec.update()
return ec return ec
@ -62,7 +62,7 @@ class ECProvider(WeatherProvider):
for zone_id in self.extra_zones: for zone_id in self.extra_zones:
if "/" in zone_id: # Basic check if it looks like EC station ID if "/" in zone_id: # Basic check if it looks like EC station ID
try: try:
ec_objects.append(ECData(station_id=zone_id)) ec_objects.append(ECWeather(station_id=zone_id))
except Exception: except Exception:
pass pass

@ -59,7 +59,7 @@ class NWSProvider(WeatherProvider):
temp_c = props.get('temperature', {}).get('value') temp_c = props.get('temperature', {}).get('value')
return CurrentConditions( return CurrentConditions(
temperature=temp_c if temp_c is not None else 0.0, temperature=temp_c if temp_c is not None else 0.0,
humidity=props.get('relativeHumidity', {}).get('value'), humidity=int(round(props.get('relativeHumidity', {}).get('value'))) if props.get('relativeHumidity', {}).get('value') is not None else None,
wind_speed=props.get('windSpeed', {}).get('value'), wind_speed=props.get('windSpeed', {}).get('value'),
wind_direction=str(props.get('windDirection', {}).get('value')), wind_direction=str(props.get('windDirection', {}).get('value')),
description=props.get('textDescription', 'Unknown') description=props.get('textDescription', 'Unknown')
@ -142,4 +142,4 @@ class NWSProvider(WeatherProvider):
expires=parse_date(props['expires']) expires=parse_date(props['expires'])
)) ))
return alerts return alerts

@ -6,7 +6,7 @@ location:
voice: voice:
# Example utilizing pico2wave (sudo apt install libttspico-utils) # Example utilizing pico2wave (sudo apt install libttspico-utils)
tts_command: 'pico2wave -w {file} "{text}"' tts_command: 'pico2wave -w /tmp/temp.wav "{text}" && sox /tmp/temp.wav -r 8000 -c 1 {file}'
nodes: nodes:
- "YOUR_NODE_NUMBER_HERE" - "YOUR_NODE_NUMBER_HERE"

@ -0,0 +1,8 @@
import env_canada
print("Contents of env_canada:")
print(dir(env_canada))
try:
from env_canada import ECData
print("ECData found!")
except ImportError:
print("ECData NOT found.")

@ -0,0 +1,59 @@
$Server = "192.168.50.150"
$User = Read-Host "Enter SSH Username for $Server (e.g. repeater or root)"
Write-Host "1. Packing files..."
# Create a tarball to avoid copying .git, venv, and permission issues
# Windows tar supports exclusions
tar --exclude ".git" --exclude "venv" --exclude "__pycache__" -cf package.tar .
Write-Host "2. Transferring package..."
scp package.tar "$($User)@$($Server):~"
if ($LASTEXITCODE -ne 0) {
Write-Error "SCP failed. Please check connectivity and credentials."
exit
}
Write-Host "3. Installing System Dependencies and Python Environment..."
# Updated remote script to handle tar unpacking
$RemoteScript = @'
echo '--- unpacking ---'
mkdir -p ~/asl3_wx_announce
mv ~/package.tar ~/asl3_wx_announce/
cd ~/asl3_wx_announce
tar -xf package.tar
rm package.tar
echo '--- Fixing Hostname ---'
sudo bash -c 'grep -q "$(hostname)" /etc/hosts || echo "127.0.0.1 $(hostname)" >> /etc/hosts'
echo '--- Updating Apt ---'
sudo apt update
sudo apt install -y python3-pip python3-venv libttspico-utils gpsd sox libsox-fmt-all
echo '--- Setting up Venv ---'
cd ~/asl3_wx_announce
python3 -m venv venv
source venv/bin/activate
echo '--- Installing Python Libs ---'
pip install -r requirements.txt
echo '--- DEBUG ENV_CANADA ---'
./venv/bin/python3 debug_ec.py
echo '--- Running Test Report ---'
# Run using the venv python
sudo ./venv/bin/python3 -m asl3_wx_announce.main --config config.yaml --report
'@
# Sanitize script for Linux (Remove Carriage Returns)
$RemoteScript = $RemoteScript -replace "`r", ""
# Base64 Encode to avoid quoting hell
$ScriptBytes = [System.Text.Encoding]::UTF8.GetBytes($RemoteScript)
$ScriptBase64 = [Convert]::ToBase64String($ScriptBytes)
# Execute via base64 decode
ssh -t "$($User)@$($Server)" "echo $ScriptBase64 | base64 -d | bash"

@ -2,6 +2,8 @@ requests
astral astral
pydantic pydantic
pyyaml pyyaml
env_canada env_canada==0.5.2
reverse_geocoder reverse_geocoder
numpy numpy
pytz

Loading…
Cancel
Save

Powered by TurnKey Linux.