Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
59ad9d8
test(cli): widen pending-ping window so snooze tests survive morning …
Multipixelone Jun 23, 2026
8b47301
feat(reliability): re-fire actionable pings after transient send failure
Multipixelone Jun 23, 2026
6fb5859
feat(reliability): retry transient LLM failures
Multipixelone Jun 23, 2026
2435602
feat(reliability): surface calendar auth failure in morning digest
Multipixelone Jun 23, 2026
05a4016
feat(reliability): cache routes + fall back when live routing is down
Multipixelone Jun 23, 2026
a957118
feat(observability): heartbeat dead-man's-switch for the poll timer
Multipixelone Jun 23, 2026
db2ca79
fix(venues): replace char-bag fuzzy match with edit-distance + digit …
Multipixelone Jun 23, 2026
895d2ec
test(routing): lock in transfer counting across walking transfers
Multipixelone Jun 23, 2026
654dab0
fix(mta): match alert routes on whole line codes, not substrings
Multipixelone Jun 23, 2026
24715da
fix(mta): carry structured stop names instead of re-parsing the summary
Multipixelone Jun 23, 2026
672c4b4
chore(cli): dedup LLM client, warn on inert scheduling keys, english …
Multipixelone Jun 23, 2026
8539900
docs: fix broken plan.md link, refresh command list, single-source ve…
Multipixelone Jun 23, 2026
d1918fd
chore(store,ci): stamp schema version; type-check tests in CI
Multipixelone Jun 23, 2026
412900e
ci: add coverage gate (pytest-cov, 80% floor)
Multipixelone Jun 23, 2026
593aae2
feat(weather): pad departure buffer when rain/snow is forecast
Multipixelone Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ jobs:
accept-flake-config = true

- name: Run tests
run: nix develop . --command pytest -q
run: nix develop . --command pytest -q --cov=commutecompass --cov-report=term-missing --cov-fail-under=80

- name: Lint
run: nix develop . --command ruff check .

- name: Type check
run: nix develop . --command mypy src
run: nix develop . --command mypy src tests

build:
name: check, build, push
Expand Down
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,14 @@ commutecompass --config examples/config.toml tomorrow --dry-run
6. **Ping firing contract**
- Use `store.claim_ping(id, now)` (atomic 0→1 transition) before
sending a notification, not `mark_fired` after. This is the race
protection against overlapping poll cycles. Send failures are
logged but NOT retried — claim-and-send is one-shot by design.
protection against overlapping poll cycles.
- On send **failure** of an actionable ping (`prep`/`leave`), the poll
loop hands the row back with `store.release_ping(id)` (atomic 1→0,
bumps `send_attempts`) so a later poll re-fires it. Re-fire is bounded:
only within `_SEND_RETRY_GRACE_SECONDS` of the scheduled `fire_at` (a
stale alarm is worse than none) and only up to `_MAX_SEND_ATTEMPTS`
(a broken notifier can never storm). Other kinds, and pings past the
grace window or attempt cap, stay fired (give up) — never retried.

7. **OpenClaw stdout protocol**
- `notify.StdoutNotifier` escapes any body line that exactly matches
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,18 @@ A self-hosted [Python](https://www.python.org/) service that pulls events from G
- [`tomorrow`](./src/commutecompass/jobs/) — push tomorrow's earliest prep time to the configured HA script (pull-model alarm)
- [`plan`](./src/commutecompass/planner.py) — replan a single event (debug)
- [`digest-preview`](./src/commutecompass/cli.py) — print today's digest from cache without sending
- [`status`](./src/commutecompass/cli.py) — show next event, pending pings, and job heartbeats (`--json` for machine output)
- [`adjust EVENT_ID --add-prep N`](./src/commutecompass/cli.py) — shift a plan's prep time by N minutes
- [`config show`](./src/commutecompass/cli.py) / [`config set KEY VALUE`](./src/commutecompass/cli.py) — view or edit allowlisted config fields
- [`snooze`](./src/commutecompass/cli.py) / [`mute`](./src/commutecompass/cli.py) / [`unmute`](./src/commutecompass/cli.py) / [`undo`](./src/commutecompass/cli.py) — adjust or suppress an event's pings
- [`config show`](./src/commutecompass/cli.py) / [`config set KEY VALUE`](./src/commutecompass/cli.py) / [`config unset KEY`](./src/commutecompass/cli.py) / [`config reset`](./src/commutecompass/cli.py) — view or edit allowlisted config fields
- [`geocode-cache`](./src/commutecompass/cli.py) — inspect or clear the geocode cache
- [`mta-alerts`](./src/commutecompass/cli.py) — print current MTA alerts
- [`test-notify`](./src/commutecompass/notify.py) — emit a test message via the configured notifier
- [`where`](./src/commutecompass/cli.py) — print the latest stored current location

## Configuration

See [`examples/config.toml`](./examples/) and [`examples/env.example`](./examples/) for the full configuration schema. Architecture and implementation notes live in [`plan.md`](./plan.md).
See [`examples/config.toml`](./examples/) and [`examples/env.example`](./examples/) for the full configuration schema. Architecture and implementation notes live in [`AGENTS.md`](./AGENTS.md).

## OpenClaw integration

Expand Down
16 changes: 16 additions & 0 deletions examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ poll_interval_seconds = 60
quiet_hours_start = "22:00"
quiet_hours_end = "07:00"

[weather]
# Pad the departure buffer when rain/snow is expected around the commute, using
# the free keyless Open-Meteo forecast. Disabled by default.
enabled = false
rain_buffer_minutes = 10
snow_buffer_minutes = 20
precip_probability_threshold = 50

[monitoring]
# Optional dead-man's-switch. poll_staleness_minutes controls how long the poll
# loop may be silent before the morning digest warns the timer is dead. Set
# heartbeat_url to a healthchecks.io-style URL to get an off-host alert when the
# per-minute poll stops pinging it entirely.
poll_staleness_minutes = 15
# heartbeat_url = "https://hc-ping.com/your-uuid-here"

[paths]
venues_file = "/etc/commutecompass/known_venues.yaml"
db_path = "/var/lib/commutecompass/state.db"
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ps: with ps; [
pip
pytest
pytest-cov
pydantic
click
pyyaml
Expand Down
4 changes: 3 additions & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

python3Packages.buildPythonApplication rec {
pname = "commutecompass";
version = "0.1.0";
# Single source of truth: read the version from pyproject.toml so it never
# drifts from the Python package metadata.
version = (lib.importTOML ../pyproject.toml).project.version;
format = "pyproject";

src = ./..;
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ mypy_path = ["src"]

[tool.pytest.ini_options]
pythonpath = ["src"]

[tool.coverage.run]
source = ["commutecompass"]
branch = true

[tool.coverage.report]
# CI enforces a floor via --cov-fail-under; current coverage is ~87%.
show_missing = true
14 changes: 12 additions & 2 deletions src/commutecompass/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def oauth(ctx: click.Context) -> None:
token_path=token_path,
)
client.authorize_interactive()
click.echo("OAuth授权完成。Token已保存。")
click.echo("OAuth authorization complete. Token saved.")


# ─────────── init-db ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -740,7 +740,11 @@ def config_show(ctx: click.Context, as_json: bool) -> None:
@click.pass_context
def config_set(ctx: click.Context, key: str, value: str) -> None:
"""Set an allowlisted config field. KEY uses dotted form (e.g. prep.prep_minutes)."""
from commutecompass.config import ConfigSetError, update_config_field
from commutecompass.config import (
EXTERNALLY_SCHEDULED_KEYS,
ConfigSetError,
update_config_field,
)

config_path: Path = ctx.obj["config_path"]
try:
Expand All @@ -752,6 +756,12 @@ def config_set(ctx: click.Context, key: str, value: str) -> None:
click.echo(f"Error writing {config_path}: {exc}", err=True)
sys.exit(EXIT_CONFIG)
click.echo(f"{key} = {coerced!r}")
if key in EXTERNALLY_SCHEDULED_KEYS:
click.echo(
f"Note: {key} is not read at runtime — the schedule is set by your "
"systemd timer or cron entry. Update that to change when the job runs.",
err=True,
)


@config.command(name="unset")
Expand Down
57 changes: 57 additions & 0 deletions src/commutecompass/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,52 @@ class NotifyConfig(BaseModel):
mode: Literal["stdout", "telegram"] = "stdout"


class WeatherConfig(BaseModel):
"""Weather-aware buffer: pad departure when rain/snow is expected.

Uses the free Open-Meteo forecast API (no key). When precipitation is
likely around the commute window, extra minutes are subtracted from the
leave time so the alarm fires earlier.
"""

enabled: bool = False
forecast_url: str = "https://api.open-meteo.com/v1/forecast"
# Extra minutes added to the buffer when rain / snow is expected.
rain_buffer_minutes: int = Field(default=10, ge=0, le=120)
snow_buffer_minutes: int = Field(default=20, ge=0, le=240)
# Minimum precipitation probability (%) before the rain buffer applies.
precip_probability_threshold: int = Field(default=50, ge=0, le=100)

@field_validator("forecast_url")
@classmethod
def _validate_forecast_url(cls, v: str) -> str:
if v and not (v.startswith("http://") or v.startswith("https://")):
raise ValueError(f"weather.forecast_url must start with http(s)://, got {v!r}")
return v.rstrip("/")


class MonitoringConfig(BaseModel):
"""Dead-man's-switch / heartbeat configuration.

``heartbeat_url`` is an optional healthchecks.io-style endpoint that the
poll job pings on every successful run; the external service alerts when the
pings stop (i.e. the per-minute timer died). ``poll_staleness_minutes`` is
the threshold past which the morning digest flags that poll has not run.
"""

heartbeat_url: Optional[str] = None
poll_staleness_minutes: int = Field(default=15, ge=1, le=24 * 60)

@field_validator("heartbeat_url")
@classmethod
def _validate_heartbeat_url(cls, v: Optional[str]) -> Optional[str]:
if v and not (v.startswith("http://") or v.startswith("https://")):
raise ValueError(
f"monitoring.heartbeat_url must start with http(s)://, got {v!r}"
)
return v


class Config(BaseModel):
origin: Origin
calendars: list[CalendarSpec]
Expand All @@ -146,6 +192,8 @@ class Config(BaseModel):
mode_overrides: list[ModeOverride] = []
home_assistant: HomeAssistantConfig = HomeAssistantConfig()
notify: NotifyConfig = NotifyConfig()
monitoring: MonitoringConfig = MonitoringConfig()
weather: WeatherConfig = WeatherConfig()
# Loaded from env, not TOML:
google_maps_api_key: str = ""
google_oauth_client_secret_json: str = ""
Expand Down Expand Up @@ -338,6 +386,15 @@ def _coerce_bool(value: str) -> bool:
}


# Keys that the app itself never reads at runtime — an external scheduler
# (systemd timer, cron) drives when `morning`/`poll` run, so editing these in
# TOML records intent but does NOT change the schedule. The CLI warns when one
# is set so a chat user isn't misled into thinking it took effect.
EXTERNALLY_SCHEDULED_KEYS: frozenset[str] = frozenset(
{"scheduling.morning_run_time", "scheduling.poll_interval_seconds"}
)


class ConfigSetError(Exception):
"""Raised by ``update_config_field`` for an invalid key or value."""

Expand Down
11 changes: 10 additions & 1 deletion src/commutecompass/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ def _format_plan_summary(plan: Plan) -> str:
if plan.route:
lines.append(f" {escape_md(_route_summary(plan.route))}")

if plan.weather_buffer_minutes > 0 and plan.weather_reason:
emoji = "❄️" if plan.weather_reason == "snow" else "🌧️"
lines.append(
f" {emoji} {escape_md(f'+{plan.weather_buffer_minutes} min for {plan.weather_reason}')}"
)

lines.append("")
return "\n".join(lines)

Expand Down Expand Up @@ -393,7 +399,10 @@ def _route_summary(route: Route) -> str:
else:
transfer_suffix = ""

return f"{mode_label} ({total_min} min{transfer_suffix})"
# Mark routes that came from cache/estimate rather than live routing.
estimate_suffix = ", estimated" if route.approximate else ""

return f"{mode_label} ({total_min} min{transfer_suffix}{estimate_suffix})"


def _route_summary_detailed(route: Route) -> str:
Expand Down
74 changes: 60 additions & 14 deletions src/commutecompass/jobs/morning.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@

import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from commutecompass.calendar_client import CalendarClient
from commutecompass.calendar_client import AuthError, CalendarClient
from commutecompass.config import Config
from commutecompass.format import format_digest, format_leave_ping, format_prep_ping
from commutecompass.mta import fetch_alerts
Expand Down Expand Up @@ -59,6 +60,7 @@ def run(config: Config) -> None: # noqa: C901
token_path=config.paths.oauth_token_path,
)
events: list[Event] = []
auth_failed = False
try:
events = calendar_client.fetch_events(
calendars=[
Expand All @@ -68,6 +70,13 @@ def run(config: Config) -> None: # noqa: C901
start=today_start,
end=today_end,
)
except AuthError as exc:
# An expired/invalid OAuth token degrades to "no events" — but unlike a
# transient API blip it will NOT fix itself, so surface it loudly in the
# digest footer instead of letting the user think their day is empty.
logger.error("Calendar auth failed — re-auth needed: %s", exc)
auth_failed = True
events = []
except Exception as exc:
logger.error("Failed to fetch calendar events: %s", exc)
# Continue with empty events list — digest will reflect no events
Expand Down Expand Up @@ -192,16 +201,10 @@ def run(config: Config) -> None: # noqa: C901
except Exception as exc:
logger.warning("Failed to fetch MTA alerts: %s", exc)

# Filter to those affecting today's planned routes
from commutecompass.llm import OpencodeGoClient
# Filter to those affecting today's planned routes. Reuse the llm_client
# built above for planning rather than constructing a second identical one.
from commutecompass.mta import select_actionable_alerts

llm_client = OpencodeGoClient(
endpoint=config.opencode_go.endpoint,
token=config.opencode_go_token,
model=config.opencode_go.model,
)

affecting_alerts: list[Alert] = []
for plan in plans:
if plan.route and plan.leave_at:
Expand All @@ -222,7 +225,10 @@ def run(config: Config) -> None: # noqa: C901
)

# ── 6. Build and send digest ──────────────────────────────────────────────
ops_notes = _operations_notes(plans, all_alerts)
poll_stale = _poll_heartbeat_stale(store, config, _now)
ops_notes = _operations_notes(
plans, all_alerts, auth_failed=auth_failed, poll_stale=poll_stale
)
digest = format_digest(plans, affecting_alerts, operations_notes=ops_notes)
notifier = build_notifier(config)
sent = notifier.send(digest)
Expand All @@ -231,32 +237,72 @@ def run(config: Config) -> None: # noqa: C901
else:
logger.warning("morning job: digest send failed")

# Record morning's own heartbeat and ping the external dead-man's-switch.
store.record_job_success("morning", _now)
if config.monitoring.heartbeat_url:
from commutecompass.monitoring import ping_heartbeat

ping_heartbeat(config.monitoring.heartbeat_url)

# ── 7. Log structured summary ────────────────────────────────────────────
unresolved = sum(1 for p in plans if p.error == "location_unresolved")
no_route = sum(1 for p in plans if p.error == "no_route")
too_imminent = sum(1 for p in plans if p.error == "too_imminent")
logger.info(
"morning_run_summary: events=%d plans=%d unresolved=%d no_route=%d "
"too_imminent=%d alerts=%d digest_sent=%s",
"too_imminent=%d alerts=%d digest_sent=%s auth_failed=%s",
len(events),
len(plans),
unresolved,
no_route,
too_imminent,
len(affecting_alerts),
sent,
auth_failed,
)


def _operations_notes(plans: list[Plan], all_alerts: list[Alert]) -> list[str]:
def _poll_heartbeat_stale(store: Store, config: Config, now: datetime) -> bool:
"""True if the poll loop has not completed within the staleness threshold.

Poll runs every minute, so by morning a healthy poll heartbeat is seconds
old. A stale (or missing) heartbeat means the per-minute timer is dead and
no leave/prep alarms will fire today — worth shouting about in the digest.
"""
from datetime import timedelta

last = store.get_job_heartbeat("poll")
if last is None:
return True
threshold = timedelta(minutes=config.monitoring.poll_staleness_minutes)
return (now - last) > threshold


def _operations_notes(
plans: list[Plan],
all_alerts: list[Alert],
*,
auth_failed: bool = False,
poll_stale: bool = False,
) -> list[str]:
"""Build the "Operations:" footer items for the morning digest.

Surfaces degraded-service signals that today would only land in stderr:
MTA feeds that went silent after retries, plans whose location couldn't
be resolved, and plans that were stored with "too_imminent" / "no_route".
calendar auth that needs re-running, a dead poll timer, MTA feeds that went
silent after retries, plans whose location couldn't be resolved, and plans
that were stored with "too_imminent" / "no_route".
"""
notes: list[str] = []

# Calendar auth failure first — without it the whole digest is empty and the
# user would otherwise have no idea their token lapsed.
if auth_failed:
notes.append("Calendar auth expired — re-run `commutecompass oauth`")

# A dead poll timer means no alarms will fire today.
if poll_stale:
notes.append("Poll loop has not run recently — alarms may not fire (check the timer)")

# Per-feed MTA failures reported by fetch_alerts (set as an attribute).
failed_feeds: list[str] = getattr(fetch_alerts, "last_failed_systems", [])
for system in failed_feeds:
Expand Down
Loading