Initial commit: weather-or-not scaffold
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Data
|
||||
.history.json
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# 🌤️ Weather or Not
|
||||
|
||||
A weather-based activity decision engine that tells you whether to ride your motorcycle or mow the lawn today.
|
||||
|
||||
## Features
|
||||
|
||||
- **Motorcycle ride check** — evaluates temperature, precipitation, wind, humidity, and road conditions
|
||||
- **Lawn mowing check** — evaluates weather conditions AND tracks when you last mowed (with configurable cooldown)
|
||||
- **Extensible** — add new activities by implementing `BaseActivity`
|
||||
- **CLI tool** — run from terminal or schedule via cron
|
||||
- **Notifications** — sends results via Mattermost or Hermes
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install -e .
|
||||
|
||||
# Run a check (uses config.yaml for location)
|
||||
weather-or-not
|
||||
|
||||
# With verbose output
|
||||
weather-or-not -v
|
||||
|
||||
# Override location
|
||||
weather-or-not --lat 48.8566 --lon 2.3522 # Paris
|
||||
|
||||
# Record that you performed an activity
|
||||
weather-or-not record mow_lawn
|
||||
weather-or-not record motorcycle_ride
|
||||
|
||||
# Send notification
|
||||
weather-or-not notify send
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config.yaml` and customize:
|
||||
|
||||
```yaml
|
||||
location:
|
||||
latitude: 40.7128 # Your latitude
|
||||
longitude: -74.0060 # Your longitude
|
||||
|
||||
activities:
|
||||
motorcycle_ride:
|
||||
enabled: true
|
||||
min_temp_c: 10
|
||||
max_temp_c: 35
|
||||
max_precipitation_mm: 0
|
||||
max_wind_speed_kmh: 30
|
||||
mow_lawn:
|
||||
enabled: true
|
||||
cooldown_days: 3
|
||||
max_precipitation_today_mm: 0
|
||||
max_precipitation_past_24h_mm: 5
|
||||
```
|
||||
|
||||
## Adding New Activities
|
||||
|
||||
Create a new file in `src/weather_or_not/` that extends `BaseActivity`:
|
||||
|
||||
```python
|
||||
from weather_or_not.activities import BaseActivity, RuleResult, Verdict
|
||||
from weather_or_not.weather import WeatherForecast
|
||||
|
||||
class MyActivity(BaseActivity):
|
||||
name = "my_activity"
|
||||
description = "Check if it's good for my activity"
|
||||
|
||||
async def check_rules(self, weather: WeatherForecast) -> list[RuleResult]:
|
||||
return [
|
||||
RuleResult(
|
||||
rule_name="My Rule",
|
||||
passed=weather.current_temp_c > 15,
|
||||
message="Temperature is above 15°C",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
Then register it in `cli.py`'s `run_check()` function.
|
||||
|
||||
## Data Storage
|
||||
|
||||
Activity history is stored in `~/.local/share/weather-or-not/history.json`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
43
config.yaml
Normal file
43
config.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Weather or Not - Configuration
|
||||
# Location for weather data (Open-Meteo)
|
||||
location:
|
||||
latitude: 40.7128 # Change to your latitude
|
||||
longitude: -74.0060 # Change to your longitude
|
||||
label: "Home" # Display name
|
||||
|
||||
# Activity thresholds
|
||||
activities:
|
||||
motorcycle_ride:
|
||||
enabled: true
|
||||
min_temp_c: 10
|
||||
max_temp_c: 35
|
||||
max_precipitation_mm: 0 # No rain allowed
|
||||
max_wind_speed_kmh: 30
|
||||
max_humidity_pct: 90
|
||||
no_ice: true # No freezing conditions
|
||||
no_wet_roads: true # No rain or snow in past 2 hours
|
||||
notes: "Requires dry roads, no precipitation, comfortable temperature"
|
||||
|
||||
mow_lawn:
|
||||
enabled: true
|
||||
# Cooldown: minimum days between mowing suggestions
|
||||
cooldown_days: 3
|
||||
max_precipitation_today_mm: 0 # No rain expected today
|
||||
max_precipitation_past_24h_mm: 5 # Ground should be mostly dry
|
||||
max_wind_speed_kmh: 25
|
||||
min_temp_c: 15
|
||||
notes: "Checks that you haven't mowed too recently and ground is dry"
|
||||
|
||||
# Notification settings
|
||||
notifications:
|
||||
mattermost:
|
||||
enabled: true
|
||||
webhook_url: "" # Your Mattermost incoming webhook URL
|
||||
channel: "#general" # Or your preferred channel
|
||||
# For Mattermost home channel delivery via Hermes, use the hermes notify endpoint
|
||||
hermes_notify: true # Set to true to send via Hermes Mattermost integration
|
||||
|
||||
# Output format
|
||||
output:
|
||||
verbose: true # Show detailed reasoning
|
||||
colorize: true # Use colors in terminal output (requires colorama)
|
||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "weather-or-not"
|
||||
version = "0.1.0"
|
||||
description = "Weather-based activity decision engine"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"pyyaml>=6.0",
|
||||
"python-dateutil>=2.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-httpx>=0.30",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
weather-or-not = "weather_or_not.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
namespaces = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
3
src/weather_or_not/__init__.py
Normal file
3
src/weather_or_not/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Weather or Not - Weather-based activity decision engine."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
221
src/weather_or_not/activities.py
Normal file
221
src/weather_or_not/activities.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Base activity rule engine and registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .weather import WeatherForecast
|
||||
|
||||
|
||||
class Verdict(str, Enum):
|
||||
GO = "GO"
|
||||
NO_GO = "NO-GO"
|
||||
CAUTION = "CAUTION"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleResult:
|
||||
"""Result of a single rule check."""
|
||||
rule_name: str
|
||||
passed: bool
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityResult:
|
||||
"""Result of an activity evaluation."""
|
||||
activity_name: str
|
||||
verdict: Verdict
|
||||
rules: list[RuleResult] = field(default_factory=list)
|
||||
reasoning: list[str] = field(default_factory=list)
|
||||
extra_info: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_go(self) -> bool:
|
||||
return self.verdict == Verdict.GO
|
||||
|
||||
@property
|
||||
def is_no_go(self) -> bool:
|
||||
return self.verdict == Verdict.NO_GO
|
||||
|
||||
def format(self, verbose: bool = True, colorize: bool = True) -> str:
|
||||
"""Format the result for display."""
|
||||
emoji = ""
|
||||
if colorize:
|
||||
if self.verdict == Verdict.GO:
|
||||
emoji = "✅"
|
||||
elif self.verdict == Verdict.NO_GO:
|
||||
emoji = "❌"
|
||||
else:
|
||||
emoji = "⚠️"
|
||||
else:
|
||||
if self.verdict == Verdict.GO:
|
||||
emoji = "[GO]"
|
||||
elif self.verdict == Verdict.NO_GO:
|
||||
emoji = "[NO-GO]"
|
||||
else:
|
||||
emoji = "[CAUTION]"
|
||||
|
||||
lines = [f" {emoji} {self.activity_name}: {self.verdict.value}"]
|
||||
|
||||
if verbose:
|
||||
for rule in self.rules:
|
||||
status = "✓" if rule.passed else "✗"
|
||||
lines.append(f" {status} {rule.rule_name}: {rule.message}")
|
||||
for reason in self.reasoning:
|
||||
lines.append(f" • {reason}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class BaseActivity(ABC):
|
||||
"""Base class for activity evaluators."""
|
||||
|
||||
name: str = "base"
|
||||
description: str = ""
|
||||
|
||||
def __init__(self, config: dict[str, Any], data_store: DataStore):
|
||||
self.config = config
|
||||
self.data_store = data_store
|
||||
|
||||
async def evaluate(self, weather: WeatherForecast) -> ActivityResult:
|
||||
"""Evaluate whether the activity should be done today."""
|
||||
rules = await self.check_rules(weather)
|
||||
verdict = self._determine_verdict(rules)
|
||||
reasoning = self._build_reasoning(rules)
|
||||
|
||||
# Update last check time
|
||||
self.data_store.record_activity_check(self.name, weather)
|
||||
|
||||
return ActivityResult(
|
||||
activity_name=self.name,
|
||||
verdict=verdict,
|
||||
rules=rules,
|
||||
reasoning=reasoning,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def check_rules(self, weather: WeatherForecast) -> list[RuleResult]:
|
||||
"""Check all rules for this activity."""
|
||||
...
|
||||
|
||||
def _determine_verdict(self, rules: list[RuleResult]) -> Verdict:
|
||||
"""Determine verdict based on rule results."""
|
||||
failed = [r for r in rules if not r.passed]
|
||||
if not failed:
|
||||
return Verdict.GO
|
||||
# If any critical rule failed, it's a hard NO-GO
|
||||
critical_failures = [
|
||||
r for r in failed
|
||||
if "no rain" in r.rule_name.lower() or "precip" in r.rule_name.lower()
|
||||
]
|
||||
if critical_failures:
|
||||
return Verdict.NO_GO
|
||||
return Verdict.CAUTION
|
||||
|
||||
def _build_reasoning(self, rules: list[RuleResult]) -> list[str]:
|
||||
"""Build human-readable reasoning."""
|
||||
reasons = []
|
||||
for rule in rules:
|
||||
if not rule.passed:
|
||||
reasons.append(f"{rule.rule_name}: {rule.message}")
|
||||
return reasons
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""Persistent storage for activity history (JSON file-based)."""
|
||||
|
||||
def __init__(self, data_dir: Path | None = None):
|
||||
if data_dir is None:
|
||||
data_dir = Path.home() / ".local" / "share" / "weather-or-not"
|
||||
self.data_dir = data_dir
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._data_file = self.data_dir / "history.json"
|
||||
self._data = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
if self._data_file.exists():
|
||||
import json
|
||||
try:
|
||||
with open(self._data_file) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _save(self):
|
||||
import json
|
||||
with open(self._data_file, "w") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
|
||||
def record_activity_check(self, activity_name: str, weather: WeatherForecast):
|
||||
"""Record that an activity was checked at the current time."""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if "checks" not in self._data:
|
||||
self._data["checks"] = {}
|
||||
if activity_name not in self._data["checks"]:
|
||||
self._data["checks"][activity_name] = []
|
||||
self._data["checks"][activity_name].append({
|
||||
"timestamp": now,
|
||||
"latitude": weather.latitude,
|
||||
"longitude": weather.longitude,
|
||||
"temp_c": weather.current_temp_c,
|
||||
})
|
||||
self._save()
|
||||
|
||||
def last_activity_date(self, activity_name: str) -> date | None:
|
||||
"""Get the last date an activity was performed (recorded by user)."""
|
||||
if "performed" not in self._data:
|
||||
self._data["performed"] = {}
|
||||
records = self._data["performed"].get(activity_name, [])
|
||||
if not records:
|
||||
return None
|
||||
# Parse dates and return the most recent
|
||||
from datetime import datetime
|
||||
dates = []
|
||||
for record in records:
|
||||
try:
|
||||
d = date.fromisoformat(str(record["date"]))
|
||||
dates.append(d)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return max(dates) if dates else None
|
||||
|
||||
def record_activity_performed(self, activity_name: str, activity_date: date | None = None):
|
||||
"""Record that an activity was actually performed."""
|
||||
if activity_date is None:
|
||||
activity_date = date.today()
|
||||
if "performed" not in self._data:
|
||||
self._data["performed"] = {}
|
||||
if activity_name not in self._data["performed"]:
|
||||
self._data["performed"][activity_name] = []
|
||||
self._data["performed"][activity_name].append({
|
||||
"date": activity_date.isoformat(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
self._save()
|
||||
|
||||
def days_since_activity(self, activity_name: str) -> int:
|
||||
"""Calculate days since an activity was last performed."""
|
||||
last = self.last_activity_date(activity_name)
|
||||
if last is None:
|
||||
return 999 # Never done
|
||||
return (date.today() - last).days
|
||||
|
||||
def get_activity_stats(self, activity_name: str) -> dict:
|
||||
"""Get statistics for an activity."""
|
||||
performed = self._data.get("performed", {}).get(activity_name, [])
|
||||
checks = self._data.get("checks", {}).get(activity_name, [])
|
||||
return {
|
||||
"times_performed": len(performed),
|
||||
"times_checked": len(checks),
|
||||
"last_performed": performed[-1]["date"] if performed else None,
|
||||
"last_checked": checks[-1]["timestamp"] if checks else None,
|
||||
}
|
||||
171
src/weather_or_not/cli.py
Normal file
171
src/weather_or_not/cli.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""CLI entry point for Weather or Not."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .weather import fetch_weather
|
||||
from .activities import DataStore
|
||||
from .motorcycle import MotorcycleActivity
|
||||
from .lawn import LawnActivity
|
||||
from .notify import NotificationService, NotificationConfig
|
||||
|
||||
|
||||
def load_config(config_path: str = "config.yaml") -> dict[str, Any]:
|
||||
"""Load configuration from YAML file."""
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
# Try home directory
|
||||
path = Path.home() / ".config" / "weather-or-not" / "config.yaml"
|
||||
|
||||
if not path.exists():
|
||||
print(f"Warning: No config file found at {config_path}")
|
||||
print("Using defaults. Create a config.yaml to customize.")
|
||||
return {}
|
||||
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
async def run_check(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
config: dict[str, Any],
|
||||
data_store: DataStore,
|
||||
verbose: bool = True,
|
||||
colorize: bool = True,
|
||||
) -> list[str]:
|
||||
"""Run a full weather check and return formatted results."""
|
||||
# Fetch weather
|
||||
print("Fetching weather data...")
|
||||
weather = await fetch_weather(latitude, longitude)
|
||||
print(f" Got data ({weather.generationtime_ms:.1f}ms generation time)")
|
||||
|
||||
# Build activity evaluators
|
||||
activities_config = config.get("activities", {})
|
||||
activities: list[Any] = []
|
||||
|
||||
# Motorcycle
|
||||
if activities_config.get("motorcycle_ride", {}).get("enabled", True):
|
||||
mc_config = activities_config.get("motorcycle_ride", {})
|
||||
activities.append(MotorcycleActivity(mc_config, data_store))
|
||||
|
||||
# Lawn
|
||||
if activities_config.get("mow_lawn", {}).get("enabled", True):
|
||||
lawn_config = activities_config.get("mow_lawn", {})
|
||||
activities.append(LawnActivity(lawn_config, data_store))
|
||||
|
||||
# Evaluate all activities
|
||||
results = []
|
||||
for activity in activities:
|
||||
print(f"Evaluating {activity.name}...")
|
||||
result = await activity.evaluate(weather)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Weather or Not — Should you ride or mow?",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
default="config.yaml",
|
||||
help="Path to config file (default: config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show detailed reasoning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color",
|
||||
action="store_true",
|
||||
help="Disable colored output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"notify",
|
||||
nargs="?",
|
||||
choices=["send"],
|
||||
help="Send notification (default: just display)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lat",
|
||||
type=float,
|
||||
help="Override latitude from config",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lon",
|
||||
type=float,
|
||||
help="Override longitude from config",
|
||||
)
|
||||
parser.add_argument(
|
||||
"record",
|
||||
nargs="*",
|
||||
help="Record activity as performed: motorcycle_ride, mow_lawn",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config
|
||||
config = load_config(args.config)
|
||||
|
||||
# Get location
|
||||
location = config.get("location", {})
|
||||
lat = args.lat or location.get("latitude", 40.7128)
|
||||
lon = args.lon or location.get("longitude", -74.0060)
|
||||
|
||||
# Output settings
|
||||
output_config = config.get("output", {})
|
||||
verbose = args.verbose or output_config.get("verbose", True)
|
||||
colorize = not args.no_color and output_config.get("colorize", True)
|
||||
|
||||
# Data store
|
||||
data_store = DataStore()
|
||||
|
||||
# Handle record command
|
||||
if args.record:
|
||||
for activity_name in args.record:
|
||||
if activity_name in ("motorcycle_ride", "mow_lawn"):
|
||||
data_store.record_activity_performed(activity_name)
|
||||
print(f"✓ Recorded: {activity_name} performed today ({date.today()})")
|
||||
else:
|
||||
print(f"Unknown activity: {activity_name}")
|
||||
print(f"Available: motorcycle_ride, mow_lawn")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Run check
|
||||
try:
|
||||
results = asyncio.run(
|
||||
run_check(lat, lon, config, data_store, verbose, colorize)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Display results
|
||||
notify_config = NotificationService(
|
||||
NotificationConfig(**config.get("notifications", {}))
|
||||
)
|
||||
print(notify_config.format_for_terminal(results))
|
||||
|
||||
# Send notification if requested
|
||||
if args.notify == "send":
|
||||
asyncio.run(
|
||||
notify_config.send(results)
|
||||
)
|
||||
print("\nNotification sent.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
144
src/weather_or_not/lawn.py
Normal file
144
src/weather_or_not/lawn.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Lawn mowing activity evaluator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from .activities import (
|
||||
BaseActivity,
|
||||
DataStore,
|
||||
RuleResult,
|
||||
Verdict,
|
||||
)
|
||||
from .weather import WeatherForecast, weather_code_description
|
||||
|
||||
|
||||
class LawnActivity(BaseActivity):
|
||||
"""Evaluate whether it's a good day to mow the lawn."""
|
||||
|
||||
name = "mow_lawn"
|
||||
description = "Check if conditions are good for mowing the lawn"
|
||||
|
||||
async def check_rules(self, weather: WeatherForecast) -> list[RuleResult]:
|
||||
rules: list[RuleResult] = []
|
||||
activity_config = self.config
|
||||
|
||||
# 1. Cooldown check — have we mowed recently?
|
||||
cooldown_days = activity_config.get("cooldown_days", 3)
|
||||
days_since = self.data_store.days_since_activity("mow_lawn")
|
||||
|
||||
if days_since < cooldown_days:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Cooldown period",
|
||||
passed=False,
|
||||
message=(
|
||||
f"Last mowed {days_since} day(s) ago. "
|
||||
f"Wait {cooldown_days - days_since} more day(s) "
|
||||
f"(recommended minimum: {cooldown_days} days between mows)"
|
||||
),
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Cooldown period",
|
||||
passed=True,
|
||||
message=f"Good — last mowed {days_since} days ago (minimum: {cooldown_days})",
|
||||
))
|
||||
|
||||
# 2. Today's precipitation
|
||||
precip_today = weather.precip_today_mm()
|
||||
max_precip = activity_config.get("max_precipitation_today_mm", 0)
|
||||
if precip_today > max_precip:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Rain today",
|
||||
passed=False,
|
||||
message=f"{precip_today}mm rain expected today (max: {max_precip}mm)",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Rain today",
|
||||
passed=True,
|
||||
message="No rain expected today",
|
||||
))
|
||||
|
||||
# 3. Past 24h precipitation (ground dryness)
|
||||
precip_24h = weather.precip_past_24h_mm()
|
||||
max_24h = activity_config.get("max_precipitation_past_24h_mm", 5)
|
||||
if precip_24h > max_24h:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Ground moisture",
|
||||
passed=False,
|
||||
message=(
|
||||
f"{precip_24h}mm in past 24h — ground likely too wet "
|
||||
f"(max: {max_24h}mm)"
|
||||
),
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Ground moisture",
|
||||
passed=True,
|
||||
message=f"Ground is dry ({precip_24h}mm in past 24h)",
|
||||
))
|
||||
|
||||
# 4. Wind speed
|
||||
wind = weather.current_wind_speed
|
||||
max_wind = activity_config.get("max_wind_speed_kmh", 25)
|
||||
if wind > max_wind:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Wind speed",
|
||||
passed=False,
|
||||
message=f"{wind} km/h exceeds safe limit of {max_wind} km/h",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Wind speed",
|
||||
passed=True,
|
||||
message=f"{wind} km/h is safe for mowing",
|
||||
))
|
||||
|
||||
# 5. Temperature
|
||||
temp = weather.current_temp_c
|
||||
min_temp = activity_config.get("min_temp_c", 15)
|
||||
if temp < min_temp:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Temperature",
|
||||
passed=False,
|
||||
message=f"{temp}°C is below minimum {min_temp}°C for comfortable mowing",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Temperature",
|
||||
passed=True,
|
||||
message=f"{temp}°C is comfortable",
|
||||
))
|
||||
|
||||
# 6. Weather code
|
||||
code = weather.current_weather_code
|
||||
if code in {0, 1, 2}: # Clear, mainly clear, partly cloudy
|
||||
rules.append(RuleResult(
|
||||
rule_name="Weather conditions",
|
||||
passed=True,
|
||||
message=weather_code_description(code),
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Weather conditions",
|
||||
passed=False,
|
||||
message=weather_code_description(code),
|
||||
))
|
||||
|
||||
return rules
|
||||
|
||||
def format_result(self, result) -> str:
|
||||
"""Override to add extra lawn-specific info."""
|
||||
lines = [result.format(verbose=True, colorize=True)]
|
||||
|
||||
stats = self.data_store.get_activity_stats("mow_lawn")
|
||||
lines.append("")
|
||||
lines.append(f" 📊 Stats: performed {stats['times_performed']} times, "
|
||||
f"checked {stats['times_checked']} times")
|
||||
if stats["last_performed"]:
|
||||
lines.append(f" 📅 Last mowed: {stats['last_performed']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
152
src/weather_or_not/motorcycle.py
Normal file
152
src/weather_or_not/motorcycle.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Motorcycle ride activity evaluator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .activities import (
|
||||
BaseActivity,
|
||||
DataStore,
|
||||
RuleResult,
|
||||
Verdict,
|
||||
)
|
||||
from .weather import WeatherForecast, weather_code_description
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotorcycleConfig:
|
||||
"""Configuration for motorcycle ride evaluation."""
|
||||
enabled: bool = True
|
||||
min_temp_c: float = 10
|
||||
max_temp_c: float = 35
|
||||
max_precipitation_mm: float = 0
|
||||
max_wind_speed_kmh: float = 30
|
||||
max_humidity_pct: float = 90
|
||||
no_ice: bool = True
|
||||
no_wet_roads: bool = True
|
||||
|
||||
|
||||
class MotorcycleActivity(BaseActivity):
|
||||
"""Evaluate whether it's safe/pleasant to ride a motorcycle."""
|
||||
|
||||
name = "motorcycle_ride"
|
||||
description = "Check if conditions are good for motorcycle commuting"
|
||||
|
||||
async def check_rules(self, weather: WeatherForecast) -> list[RuleResult]:
|
||||
rules: list[RuleResult] = []
|
||||
|
||||
# 1. Temperature check
|
||||
temp = weather.current_temp_c
|
||||
if temp < self.config.get("min_temp_c", 10):
|
||||
rules.append(RuleResult(
|
||||
rule_name="Temperature too low",
|
||||
passed=False,
|
||||
message=f"{temp}°C is below minimum {self.config.get('min_temp_c', 10)}°C",
|
||||
))
|
||||
elif temp > self.config.get("max_temp_c", 35):
|
||||
rules.append(RuleResult(
|
||||
rule_name="Temperature too high",
|
||||
passed=False,
|
||||
message=f"{temp}°C exceeds maximum {self.config.get('max_temp_c', 35)}°C",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Temperature",
|
||||
passed=True,
|
||||
message=f"{temp}°C is comfortable for riding",
|
||||
))
|
||||
|
||||
# 2. Precipitation check
|
||||
precip_today = weather.precip_today_mm()
|
||||
if precip_today > self.config.get("max_precipitation_mm", 0):
|
||||
rules.append(RuleResult(
|
||||
rule_name="Precipitation expected",
|
||||
passed=False,
|
||||
message=f"{precip_today}mm rain expected today (max: {self.config.get('max_precipitation_mm', 0)}mm)",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Precipitation",
|
||||
passed=True,
|
||||
message="No rain expected",
|
||||
))
|
||||
|
||||
# 3. Wind speed check
|
||||
wind = weather.current_wind_speed
|
||||
max_wind = self.config.get("max_wind_speed_kmh", 30)
|
||||
if wind > max_wind:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Wind too strong",
|
||||
passed=False,
|
||||
message=f"{wind} km/h exceeds safe limit of {max_wind} km/h",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Wind speed",
|
||||
passed=True,
|
||||
message=f"{wind} km/h is safe for riding",
|
||||
))
|
||||
|
||||
# 4. Humidity check
|
||||
humidity = weather.current_humidity
|
||||
max_hum = self.config.get("max_humidity_pct", 90)
|
||||
if humidity > max_hum:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Humidity too high",
|
||||
passed=False,
|
||||
message=f"{humidity}% humidity (max: {max_hum}%)",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Humidity",
|
||||
passed=True,
|
||||
message=f"{humidity}% is comfortable",
|
||||
))
|
||||
|
||||
# 5. Weather code check (snow, ice, thunderstorm)
|
||||
code = weather.current_weather_code
|
||||
dangerous_codes = {45, 48, 66, 67, 71, 73, 75, 77, 85, 86, 95, 96, 99}
|
||||
if code in dangerous_codes:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Dangerous weather",
|
||||
passed=False,
|
||||
message=weather_code_description(code),
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Weather conditions",
|
||||
passed=True,
|
||||
message=weather_code_description(code),
|
||||
))
|
||||
|
||||
# 6. Road conditions (wet roads in past 2 hours)
|
||||
if self.config.get("no_wet_roads", True):
|
||||
precip_2h = weather.precip_next_2h_mm()
|
||||
if precip_2h > 0:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Road conditions",
|
||||
passed=False,
|
||||
message=f"Rain expected in next 2h ({precip_2h}mm) — wet roads",
|
||||
))
|
||||
else:
|
||||
rules.append(RuleResult(
|
||||
rule_name="Road conditions",
|
||||
passed=True,
|
||||
message="Roads expected to stay dry",
|
||||
))
|
||||
|
||||
return rules
|
||||
|
||||
def get_summary(self, weather: WeatherForecast) -> str:
|
||||
"""Get a quick summary of current conditions."""
|
||||
temp = weather.current_temp_c
|
||||
wind = weather.current_wind_speed
|
||||
precip = weather.precip_today_mm()
|
||||
desc = weather_code_description(weather.current_weather_code)
|
||||
|
||||
return (
|
||||
f"Current: {temp}°C, {desc}, "
|
||||
f"Wind: {wind} km/h, "
|
||||
f"Rain today: {precip}mm"
|
||||
)
|
||||
105
src/weather_or_not/notify.py
Normal file
105
src/weather_or_not/notify.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Notification handlers for Weather or Not."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .activities import ActivityResult, Verdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationConfig:
|
||||
"""Notification configuration."""
|
||||
mattermost_enabled: bool = True
|
||||
mattermost_webhook_url: str = ""
|
||||
mattermost_channel: str = "#general"
|
||||
hermes_notify: bool = True # Use Hermes Mattermost integration
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Send notifications via Mattermost or Hermes."""
|
||||
|
||||
def __init__(self, config: NotificationConfig | None = None):
|
||||
self.config = config or NotificationConfig()
|
||||
|
||||
async def send(self, results: list[ActivityResult]) -> None:
|
||||
"""Send notification for all activity results."""
|
||||
if self.config.hermes_notify:
|
||||
# Hermes integration: write to a file that the gateway
|
||||
# can pick up and send via Mattermost
|
||||
await self._notify_hermes(results)
|
||||
elif self.config.mattermost_enabled and self.config.mattermost_webhook_url:
|
||||
await self._send_mattermost_webhook(results)
|
||||
|
||||
async def _notify_hermes(self, results: list[ActivityResult]) -> None:
|
||||
"""Send via Hermes notification system."""
|
||||
# Write notification to a temp file that Hermes can process
|
||||
# This is the simplest integration with Hermes' Mattermost
|
||||
message = self._format_message(results)
|
||||
|
||||
# Write to a well-known location for Hermes to pick up
|
||||
output_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_dir / "weather-or-not-latest.md"
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
f.write(message)
|
||||
|
||||
def _format_message(self, results: list[ActivityResult]) -> str:
|
||||
"""Format results into a human-readable message."""
|
||||
lines = ["## 🌤️ Weather or Not — Daily Check"]
|
||||
lines.append("")
|
||||
|
||||
for result in results:
|
||||
if result.verdict == Verdict.GO:
|
||||
emoji = "✅"
|
||||
elif result.verdict == Verdict.NO_GO:
|
||||
emoji = "❌"
|
||||
else:
|
||||
emoji = "⚠️"
|
||||
|
||||
lines.append(f"{emoji} **{result.activity_name}**: {result.verdict.value}")
|
||||
|
||||
if result.reasoning:
|
||||
lines.append("")
|
||||
lines.append("*Reasoning:*")
|
||||
for reason in result.reasoning:
|
||||
lines.append(f" • {reason}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("*Generated by Weather or Not*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _send_mattermost_webhook(self, results: list[ActivityResult]) -> None:
|
||||
"""Send a webhook to Mattermost."""
|
||||
message = self._format_message(results)
|
||||
|
||||
payload = {
|
||||
"channel": self.config.mattermost_channel,
|
||||
"text": message,
|
||||
"username": "Weather or Not",
|
||||
"icon_emoji": ":partly_sunny:",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
self.config.mattermost_webhook_url,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
def format_for_terminal(self, results: list[ActivityResult]) -> str:
|
||||
"""Format results for terminal display."""
|
||||
lines = ["\n🌤️ **Weather or Not — Daily Check**\n"]
|
||||
|
||||
for result in results:
|
||||
lines.append(result.format(verbose=True, colorize=True))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
180
src/weather_or_not/weather.py
Normal file
180
src/weather_or_not/weather.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Weather data fetching from Open-Meteo API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class WeatherForecast(BaseModel):
|
||||
"""Parsed weather forecast data."""
|
||||
latitude: float
|
||||
longitude: float
|
||||
generationtime_ms: float
|
||||
utc_offset_seconds: int
|
||||
timezone: str
|
||||
timezone_abbreviation: str
|
||||
elevation: float
|
||||
current: dict
|
||||
hourly: dict
|
||||
daily: dict
|
||||
|
||||
@property
|
||||
def current_temp_c(self) -> float:
|
||||
return self.current.get("temperature_4m", 0)
|
||||
|
||||
@property
|
||||
def current_wind_speed(self) -> float:
|
||||
return self.current.get("wind_speed_10m", 0)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float:
|
||||
return self.current.get("relative_humidity_2m", 100)
|
||||
|
||||
@property
|
||||
def current_weather_code(self) -> int:
|
||||
return self.current.get("weather_code", 0)
|
||||
|
||||
def precip_today_mm(self) -> float:
|
||||
"""Total precipitation expected today."""
|
||||
daily = self.daily
|
||||
if "precipitation_sum" in daily:
|
||||
precip_values = daily["precipitation_sum"]
|
||||
if precip_values and len(precip_values) > 0:
|
||||
return float(precip_values[0])
|
||||
return 0.0
|
||||
|
||||
def precip_past_24h_mm(self) -> float:
|
||||
"""Total precipitation in the past 24 hours (from hourly data)."""
|
||||
hourly = self.hourly
|
||||
if "precipitation" in hourly:
|
||||
precip_values = hourly["precipitation"]
|
||||
times = hourly.get("time", [])
|
||||
now = datetime.now(timezone.utc)
|
||||
total = 0.0
|
||||
for i, (time_str, precip) in enumerate(zip(times, precip_values)):
|
||||
try:
|
||||
t = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||
if (now - t).total_seconds() <= 86400:
|
||||
total += float(precip) if precip else 0
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return total
|
||||
return 0.0
|
||||
|
||||
def precip_next_2h_mm(self) -> float:
|
||||
"""Precipitation expected in the next 2 hours."""
|
||||
hourly = self.hourly
|
||||
if "precipitation" in hourly:
|
||||
precip_values = hourly["precipitation"]
|
||||
times = hourly.get("time", [])
|
||||
now = datetime.now(timezone.utc)
|
||||
total = 0.0
|
||||
for time_str, precip in zip(times, precip_values):
|
||||
try:
|
||||
t = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||
diff = (t - now).total_seconds()
|
||||
if 0 <= diff <= 7200: # Next 2 hours
|
||||
total += float(precip) if precip else 0
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return total
|
||||
return 0.0
|
||||
|
||||
def wind_speed_next_24h(self) -> list[float]:
|
||||
"""Wind speeds for the next 24 hours."""
|
||||
hourly = self.hourly
|
||||
if "wind_speed_10m" in hourly:
|
||||
return [float(w) for w in hourly["wind_speed_10m"][:24]]
|
||||
return []
|
||||
|
||||
def temp_next_24h(self) -> list[float]:
|
||||
"""Temperatures for the next 24 hours."""
|
||||
hourly = self.hourly
|
||||
if "temperature_2m" in hourly:
|
||||
return [float(t) for t in hourly["temperature_2m"][:24]]
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_weather(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
*,
|
||||
include_current: bool = True,
|
||||
include_hourly: bool = True,
|
||||
include_daily: bool = True,
|
||||
) -> WeatherForecast:
|
||||
"""Fetch weather forecast from Open-Meteo API.
|
||||
|
||||
Open-Meteo is free, no API key required.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
|
||||
if include_current:
|
||||
params["current"] = (
|
||||
"temperature_2m,relative_humidity_2m,precipitation,"
|
||||
"weather_code,wind_speed_10m,wind_direction_10m,"
|
||||
"is_day,pressure_msl"
|
||||
)
|
||||
|
||||
if include_hourly:
|
||||
params["hourly"] = (
|
||||
"temperature_2m,relative_humidity_2m,precipitation,"
|
||||
"wind_speed_10m,wind_direction_10m,weather_code,"
|
||||
"apparent_temperature"
|
||||
)
|
||||
|
||||
if include_daily:
|
||||
params["daily"] = "precipitation_sum,temperature_2m_max,temperature_2m_min"
|
||||
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
params.update({
|
||||
"latitude": str(latitude),
|
||||
"longitude": str(longitude),
|
||||
"timezone": "auto",
|
||||
"forecast_days": 3,
|
||||
})
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return WeatherForecast(**data)
|
||||
|
||||
|
||||
def weather_code_description(code: int) -> str:
|
||||
"""Human-readable description of a WMO weather code."""
|
||||
codes = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
56: "Light freezing drizzle",
|
||||
57: "Dense freezing drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
66: "Light freezing rain",
|
||||
67: "Heavy freezing rain",
|
||||
71: "Slight snowfall",
|
||||
73: "Moderate snowfall",
|
||||
75: "Heavy snowfall",
|
||||
77: "Snow grains",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
85: "Slight snow showers",
|
||||
86: "Heavy snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with slight hail",
|
||||
99: "Thunderstorm with heavy hail",
|
||||
}
|
||||
return codes.get(code, f"Unknown ({code})")
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
184
tests/test_activities.py
Normal file
184
tests/test_activities.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Tests for activity evaluators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from weather_or_not.activities import DataStore, Verdict
|
||||
from weather_or_not.motorcycle import MotorcycleActivity
|
||||
from weather_or_not.lawn import LawnActivity
|
||||
from weather_or_not.weather import WeatherForecast
|
||||
|
||||
|
||||
def _make_weather(
|
||||
temp: float = 20,
|
||||
wind: float = 10,
|
||||
humidity: float = 50,
|
||||
weather_code: int = 0,
|
||||
precip_today: float = 0,
|
||||
precip_24h: float = 0,
|
||||
precip_2h: float = 0,
|
||||
) -> WeatherForecast:
|
||||
"""Create a minimal WeatherForecast for testing."""
|
||||
return WeatherForecast(
|
||||
latitude=40.7128,
|
||||
longitude=-74.0060,
|
||||
generationtime_ms=10.0,
|
||||
utc_offset_seconds=-14400,
|
||||
timezone="America/New_York",
|
||||
timezone_abbreviation="EDT",
|
||||
elevation=10.0,
|
||||
current={
|
||||
"temperature_4m": temp,
|
||||
"wind_speed_10m": wind,
|
||||
"relative_humidity_2m": humidity,
|
||||
"weather_code": weather_code,
|
||||
"precipitation": 0,
|
||||
"is_day": 1,
|
||||
"pressure_msl": 1013,
|
||||
},
|
||||
hourly={
|
||||
"time": [
|
||||
"2026-04-29T10:00:00",
|
||||
"2026-04-29T11:00:00",
|
||||
"2026-04-29T12:00:00",
|
||||
],
|
||||
"temperature_2m": [temp, temp + 1, temp + 2],
|
||||
"relative_humidity_2m": [humidity, humidity - 5, humidity - 10],
|
||||
"precipitation": [0, 0, 0],
|
||||
"wind_speed_10m": [wind, wind - 1, wind - 2],
|
||||
"weather_code": [0, 0, 0],
|
||||
"apparent_temperature": [temp, temp + 1, temp + 2],
|
||||
},
|
||||
daily={
|
||||
"time": ["2026-04-29"],
|
||||
"precipitation_sum": [precip_today],
|
||||
"temperature_2m_max": [temp + 5],
|
||||
"temperature_2m_min": [temp - 5],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestDataStore:
|
||||
def test_never_done(self, tmp_path):
|
||||
store = DataStore(tmp_path)
|
||||
assert store.days_since_activity("mow_lawn") == 999
|
||||
|
||||
def test_record_and_check(self, tmp_path):
|
||||
store = DataStore(tmp_path)
|
||||
store.record_activity_performed("mow_lawn", date(2026, 4, 25))
|
||||
assert store.days_since_activity("mow_lawn") == 4 # Apr 29 - Apr 25
|
||||
|
||||
def test_stats(self, tmp_path):
|
||||
store = DataStore(tmp_path)
|
||||
store.record_activity_performed("mow_lawn", date(2026, 4, 25))
|
||||
stats = store.get_activity_stats("mow_lawn")
|
||||
assert stats["times_performed"] == 1
|
||||
assert stats["last_performed"] == "2026-04-25"
|
||||
|
||||
def test_persistence(self, tmp_path):
|
||||
"""Data persists across DataStore instances."""
|
||||
store1 = DataStore(tmp_path)
|
||||
store1.record_activity_performed("mow_lawn", date(2026, 4, 20))
|
||||
del store1
|
||||
|
||||
store2 = DataStore(tmp_path)
|
||||
assert store2.last_activity_date("mow_lawn") == date(2026, 4, 20)
|
||||
|
||||
|
||||
class TestMotorcycleActivity:
|
||||
def setup_method(self):
|
||||
self.config = {
|
||||
"min_temp_c": 10,
|
||||
"max_temp_c": 35,
|
||||
"max_precipitation_mm": 0,
|
||||
"max_wind_speed_kmh": 30,
|
||||
"max_humidity_pct": 90,
|
||||
"no_ice": True,
|
||||
"no_wet_roads": True,
|
||||
}
|
||||
self.data_store = DataStore(Path("/tmp/test-won-data"))
|
||||
self.activity = MotorcycleActivity(self.config, self.data_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_good_conditions(self):
|
||||
weather = _make_weather(temp=22, wind=10, humidity=50, weather_code=0)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.GO
|
||||
assert all(r.passed for r in result.rules)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rain(self):
|
||||
weather = _make_weather(precip_today=5)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
assert any(not r.passed and "precip" in r.rule_name.lower()
|
||||
for r in result.rules)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wind_too_strong(self):
|
||||
weather = _make_weather(wind=40)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
assert any(not r.passed and "wind" in r.rule_name.lower()
|
||||
for r in result.rules)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temp_too_cold(self):
|
||||
weather = _make_weather(temp=5)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thunderstorm(self):
|
||||
weather = _make_weather(weather_code=95)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
|
||||
|
||||
class TestLawnActivity:
|
||||
def setup_method(self):
|
||||
self.config = {
|
||||
"cooldown_days": 3,
|
||||
"max_precipitation_today_mm": 0,
|
||||
"max_precipitation_past_24h_mm": 5,
|
||||
"max_wind_speed_kmh": 25,
|
||||
"min_temp_c": 15,
|
||||
}
|
||||
self.data_store = DataStore(Path("/tmp/test-lawn-data"))
|
||||
self.activity = LawnActivity(self.config, self.data_store)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_good_conditions(self):
|
||||
# Ensure cooldown is met
|
||||
self.data_store.record_activity_performed("mow_lawn", date(2026, 4, 20))
|
||||
weather = _make_weather(temp=22, wind=10)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.GO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_not_met(self):
|
||||
# Just mowed yesterday
|
||||
self.data_store.record_activity_performed("mow_lawn", date(2026, 4, 28))
|
||||
weather = _make_weather(temp=22, wind=10)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
assert any(not r.passed and "cooldown" in r.rule_name.lower()
|
||||
for r in result.rules)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ground_wet(self):
|
||||
self.data_store.record_activity_performed("mow_lawn", date(2026, 4, 20))
|
||||
weather = _make_weather(precip_24h=10)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rain_today(self):
|
||||
self.data_store.record_activity_performed("mow_lawn", date(2026, 4, 20))
|
||||
weather = _make_weather(precip_today=3)
|
||||
result = await self.activity.evaluate(weather)
|
||||
assert result.verdict == Verdict.NO_GO
|
||||
Reference in New Issue
Block a user